Compare commits
31
Commits
main
...
v2.0.0-preview
@@ -0,0 +1,12 @@
|
||||
[registry]
|
||||
default = "gitea"
|
||||
|
||||
[registries.gitea]
|
||||
index = "sparse+https://gitea.creative-dragonslayer.de/api/packages/Rust-Crates/cargo/" # Sparse index
|
||||
# index = "https://gitea.creative-dragonslayer.de/Rust-Crates/_cargo-index.git" # Git
|
||||
|
||||
[net]
|
||||
git-fetch-with-cli = true
|
||||
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Code Quality (Auto-Format & Clippy-Fix)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
fix:
|
||||
name: Formatierung & Clippy automatisch beheben
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ gitea.ref_name || github.ref_name }}
|
||||
token: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }}
|
||||
|
||||
- name: Install Rust Toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v2
|
||||
with:
|
||||
toolchain: stable
|
||||
components: clippy, rustfmt
|
||||
cache: false
|
||||
|
||||
- name: Cache Cargo-Abhängigkeiten & Build-Artefakte
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: |
|
||||
cargo-${{ runner.os }}-
|
||||
|
||||
- name: Formatierung automatisch beheben
|
||||
run: cargo fmt
|
||||
|
||||
- name: Clippy-Fixes automatisch anwenden
|
||||
run: cargo clippy --fix --allow-dirty --allow-staged --all-targets
|
||||
|
||||
- name: Änderungen committen & pushen
|
||||
run: |
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
git config user.name "Gitea-Bot"
|
||||
git config user.email "no-reply@creativedragonslayer.de"
|
||||
git add -A
|
||||
git commit -m "Style: Automatische Formatierung & Clippy-Fixes"
|
||||
git push origin HEAD:${{ gitea.ref_name || github.ref_name }}
|
||||
else
|
||||
echo "Keine Formatierungs- oder Clippy-Änderungen."
|
||||
fi
|
||||
@@ -0,0 +1,249 @@
|
||||
name: Main Release & Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
detect-changes:
|
||||
name: Erkenne relevante Code-Änderungen
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
code_changed: ${{ steps.filter.outputs.code }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Prüfe auf Änderungen am Programmcode
|
||||
uses: dorny/paths-filter@v4
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
code:
|
||||
- 'src/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'scripts/**'
|
||||
- '.cargo/**'
|
||||
- '.gitea/workflows/main.yaml'
|
||||
|
||||
release-and-publish:
|
||||
name: Build, Publish Packages (Stable) & Create Release
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.code_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CARGO_BINSTALL_VERSION: "1.23.0"
|
||||
CARGO_DEB_VERSION: "3.8.0"
|
||||
CARGO_GENERATE_RPM_VERSION: "0.21.0"
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Rust Toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v2
|
||||
with:
|
||||
toolchain: stable
|
||||
cache: false
|
||||
|
||||
- name: Cache Cargo-Abhängigkeiten & Build-Artefakte
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: |
|
||||
cargo-${{ runner.os }}-
|
||||
|
||||
- name: Alte Paketierungs-Ausgaben aus dem Cache entfernen
|
||||
run: rm -rf target/debian target/generate-rpm target/arch
|
||||
|
||||
- name: Install Cross-Compilation Toolchains (apt)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||
|
||||
- name: Ermittle Rust-Version für Rustup-Target-Cache-Key
|
||||
run: echo "RUST_VERSION=$(rustc --version | awk '{print $2}')" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Cache Rustup Cross-Compilation-Targets
|
||||
id: cache-rustup-targets
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/aarch64-unknown-linux-gnu
|
||||
key: rustup-targets-${{ runner.os }}-${{ env.RUST_VERSION }}
|
||||
|
||||
- name: Add Rust Cross-Compilation Targets
|
||||
if: steps.cache-rustup-targets.outputs.cache-hit != 'true'
|
||||
run: rustup target add aarch64-unknown-linux-gnu
|
||||
|
||||
- name: PATH um Cargo-bin-Verzeichnis ergänzen
|
||||
run: |
|
||||
mkdir -p ~/.cargo/bin
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Cache Packaging-Tools (cargo-binstall, cargo-deb, cargo-generate-rpm)
|
||||
id: cache-packaging-tools
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/cargo-binstall
|
||||
~/.cargo/bin/cargo-deb
|
||||
~/.cargo/bin/cargo-generate-rpm
|
||||
key: packaging-tools-${{ runner.os }}-${{ env.CARGO_BINSTALL_VERSION }}-${{ env.CARGO_DEB_VERSION }}-${{ env.CARGO_GENERATE_RPM_VERSION }}
|
||||
|
||||
- name: Install Packaging Tools (Prebuilt Binaries)
|
||||
if: steps.cache-packaging-tools.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
curl -fsSL "https://github.com/cargo-bins/cargo-binstall/releases/download/v${CARGO_BINSTALL_VERSION}/cargo-binstall-x86_64-unknown-linux-musl.tgz" | tar -xz -C ~/.cargo/bin
|
||||
~/.cargo/bin/cargo-binstall -y --no-symlinks "cargo-deb@${CARGO_DEB_VERSION}" "cargo-generate-rpm@${CARGO_GENERATE_RPM_VERSION}"
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
cargo test
|
||||
|
||||
- name: Build Release Binaries
|
||||
run: |
|
||||
cargo build --release --target x86_64-unknown-linux-gnu
|
||||
cargo build --release --target aarch64-unknown-linux-gnu
|
||||
|
||||
- name: Determine Build Number
|
||||
id: build_num
|
||||
env:
|
||||
GITEA_URL: ${{ gitea.server_url || github.server_url }}
|
||||
REPO: ${{ gitea.repository || github.repository }}
|
||||
REPO_OWNER: ${{ gitea.repository_owner || github.repository_owner }}
|
||||
TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }}
|
||||
run: |
|
||||
BUILD_NUM=$(python3 scripts/get-build-number.py)
|
||||
echo "build_number=${BUILD_NUM}" >> $GITHUB_OUTPUT
|
||||
echo "BUILD_NUMBER=${BUILD_NUM}" >> $GITHUB_ENV
|
||||
echo "Ermittelte Build-Nummer: ${BUILD_NUM}"
|
||||
|
||||
- name: Build Debian Packages (.deb)
|
||||
run: |
|
||||
cargo deb --target x86_64-unknown-linux-gnu --deb-revision "${BUILD_NUMBER}" --no-build
|
||||
cargo deb --target aarch64-unknown-linux-gnu --deb-revision "${BUILD_NUMBER}" --no-build
|
||||
|
||||
- name: Build Fedora / RPM Packages (.rpm)
|
||||
run: |
|
||||
mkdir -p target/generate-rpm
|
||||
cargo generate-rpm --target x86_64-unknown-linux-gnu -s "release=\"${BUILD_NUMBER}\"" -o target/generate-rpm
|
||||
cargo generate-rpm --target aarch64-unknown-linux-gnu -s "release=\"${BUILD_NUMBER}\"" -o target/generate-rpm
|
||||
|
||||
- name: Build Arch Linux Packages (.pkg.tar.zst)
|
||||
run: |
|
||||
python3 scripts/package-arch.py --target x86_64-unknown-linux-gnu --pkgrel "${BUILD_NUMBER}"
|
||||
python3 scripts/package-arch.py --target aarch64-unknown-linux-gnu --pkgrel "${BUILD_NUMBER}"
|
||||
|
||||
- name: Publish Packages to Gitea Package Registry
|
||||
env:
|
||||
GITEA_URL: ${{ gitea.server_url || github.server_url }}
|
||||
REPO_OWNER: ${{ gitea.repository_owner || github.repository_owner }}
|
||||
TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }}
|
||||
run: |
|
||||
echo "Veröffentliche Debian-Paket (Distribution: stable, Component: main)..."
|
||||
for deb in target/debian/*.deb; do
|
||||
[ -f "$deb" ] || continue
|
||||
curl -f -s -S -X PUT \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
--upload-file "$deb" \
|
||||
"${GITEA_URL}/api/packages/${REPO_OWNER}/debian/pool/stable/main/upload"
|
||||
done
|
||||
|
||||
echo "Veröffentliche Fedora/RPM-Paket (Gruppe: stable)..."
|
||||
for rpm in target/generate-rpm/*.rpm; do
|
||||
[ -f "$rpm" ] || continue
|
||||
curl -f -s -S -X PUT \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
--upload-file "$rpm" \
|
||||
"${GITEA_URL}/api/packages/${REPO_OWNER}/rpm/stable/upload"
|
||||
done
|
||||
|
||||
echo "Veröffentliche Arch Linux-Paket (Repository: stable)..."
|
||||
for pkg in target/arch/*.pkg.tar.zst; do
|
||||
[ -f "$pkg" ] || continue
|
||||
curl -f -s -S -X PUT \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
--upload-file "$pkg" \
|
||||
"${GITEA_URL}/api/packages/${REPO_OWNER}/arch/stable"
|
||||
done
|
||||
|
||||
- name: Create Gitea Release and Upload Assets
|
||||
env:
|
||||
GITEA_URL: ${{ gitea.server_url || github.server_url }}
|
||||
REPO: ${{ gitea.repository || github.repository }}
|
||||
REPO_NAME: ${{ gitea.repository_name || github.event.repository.name }}
|
||||
TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }}
|
||||
run: |
|
||||
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
|
||||
TAG_NAME="v${VERSION}"
|
||||
RELEASE_TITLE="Release ${TAG_NAME}"
|
||||
RELEASE_NOTES="Automatisches Release für ${REPO_NAME} ${VERSION}."
|
||||
|
||||
echo "Erstelle oder hole Release für Tag ${TAG_NAME} in ${REPO}..."
|
||||
|
||||
GET_RESP=$(curl -s -w "\n%{http_code}" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG_NAME}")
|
||||
HTTP_CODE=$(echo "$GET_RESP" | tail -n1)
|
||||
BODY=$(echo "$GET_RESP" | sed '$d')
|
||||
|
||||
RELEASE_ID=""
|
||||
if [ "$HTTP_CODE" -eq 200 ]; then
|
||||
RELEASE_ID=$(echo "$BODY" | jq -r '.id // empty' 2>/dev/null || echo "$BODY" | grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2)
|
||||
echo "Bestehendes Release gefunden (ID: ${RELEASE_ID})."
|
||||
else
|
||||
echo "Erstelle neues Release ${TAG_NAME}..."
|
||||
CREATE_PAYLOAD=$(cat <<EOF
|
||||
{
|
||||
"tag_name": "${TAG_NAME}",
|
||||
"target_commitish": "main",
|
||||
"name": "${RELEASE_TITLE}",
|
||||
"body": "${RELEASE_NOTES}",
|
||||
"draft": false,
|
||||
"prerelease": false
|
||||
}
|
||||
EOF
|
||||
)
|
||||
CREATE_RESP=$(curl -f -s -S -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$CREATE_PAYLOAD" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases")
|
||||
RELEASE_ID=$(echo "$CREATE_RESP" | jq -r '.id // empty' 2>/dev/null || echo "$CREATE_RESP" | grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2)
|
||||
echo "Neues Release erstellt (ID: ${RELEASE_ID})."
|
||||
fi
|
||||
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
echo "Fehler: Release-ID konnte nicht ermittelt werden!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
EXISTING_ASSETS_JSON=$(curl -s \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" || echo "[]")
|
||||
|
||||
for file in target/debian/*.deb target/generate-rpm/*.rpm target/arch/*.pkg.tar.zst; do
|
||||
[ -f "$file" ] || continue
|
||||
filename="$(basename "$file")"
|
||||
echo "Lade Release-Asset hoch: $filename"
|
||||
|
||||
ASSET_ID=$(echo "$EXISTING_ASSETS_JSON" | jq -r --arg name "$filename" '.[]? | select(.name == $name) | .id' 2>/dev/null | head -n1 || true)
|
||||
if [ -n "$ASSET_ID" ] && [ "$ASSET_ID" != "null" ]; then
|
||||
echo "Lösche altes Asset mit ID ${ASSET_ID}..."
|
||||
curl -s -X DELETE \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}" || true
|
||||
fi
|
||||
|
||||
curl -f -s -S -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-F "attachment=@${file}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
||||
echo "Asset ${filename} erfolgreich hochgeladen."
|
||||
done
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Renovate
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
name: Dependency-Updates prüfen & Pull Requests erstellen
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/renovatebot/renovate:44.82.0
|
||||
steps:
|
||||
- name: Renovate ausführen
|
||||
run: renovate
|
||||
env:
|
||||
RENOVATE_PLATFORM: gitea
|
||||
RENOVATE_ENDPOINT: ${{ gitea.server_url || github.server_url }}/api/v1/
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
RENOVATE_REPOSITORIES: ${{ gitea.repository || github.repository }}
|
||||
RENOVATE_AUTODISCOVER: "false"
|
||||
RENOVATE_ALLOW_CUSTOM_CRATE_REGISTRIES: "true"
|
||||
RENOVATE_GIT_AUTHOR: "Renovate Bot <renovate-bot@creativedragonslayer.de>"
|
||||
RENOVATE_HOST_RULES: >-
|
||||
[{"hostType":"cargo","matchHost":"${{ gitea.server_url || github.server_url }}","token":"${{ secrets.RENOVATE_TOKEN }}"}]
|
||||
GITHUB_COM_TOKEN: ${{ secrets.GH_RENOVATE_TOKEN }}
|
||||
LOG_LEVEL: info
|
||||
@@ -0,0 +1,95 @@
|
||||
name: Security Scans
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- testing
|
||||
- dev
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "0 5 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
security-scan:
|
||||
name: Trivy & OSV-Scanner
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TRIVY_VERSION: "0.74.0"
|
||||
OSV_SCANNER_VERSION: "2.5.1"
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Lokales bin-Verzeichnis zum PATH hinzufügen
|
||||
run: |
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Cache Trivy-Binary
|
||||
id: cache-trivy
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.local/bin/trivy
|
||||
key: trivy-bin-${{ runner.os }}-${{ env.TRIVY_VERSION }}
|
||||
|
||||
- name: Install Trivy
|
||||
if: steps.cache-trivy.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
curl -fsSL -o trivy.tar.gz \
|
||||
"https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz"
|
||||
tar -xzf trivy.tar.gz trivy
|
||||
chmod +x trivy
|
||||
mv trivy "$HOME/.local/bin/trivy"
|
||||
rm -f trivy.tar.gz
|
||||
|
||||
- name: Ermittle Cache-Datum für Trivy-DB
|
||||
run: echo "CACHE_DATE=$(date -u +%Y-%m-%d)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Cache Trivy-Schwachstellen-Datenbank
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/trivy
|
||||
key: trivy-db-${{ runner.os }}-${{ env.CACHE_DATE }}
|
||||
restore-keys: |
|
||||
trivy-db-${{ runner.os }}-
|
||||
|
||||
- name: Run Trivy Scanner
|
||||
run: |
|
||||
trivy fs \
|
||||
--scanners vuln,secret,misconfig \
|
||||
--severity CRITICAL,HIGH \
|
||||
--format json \
|
||||
--output trivy-results.json \
|
||||
--exit-code 0 \
|
||||
.
|
||||
|
||||
- name: Cache OSV-Scanner-Binary
|
||||
id: cache-osv-scanner
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.local/bin/osv-scanner
|
||||
key: osv-scanner-bin-${{ runner.os }}-${{ env.OSV_SCANNER_VERSION }}
|
||||
|
||||
- name: Install OSV-Scanner
|
||||
if: steps.cache-osv-scanner.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
curl -fsSL -o "$HOME/.local/bin/osv-scanner" \
|
||||
"https://github.com/google/osv-scanner/releases/download/v${OSV_SCANNER_VERSION}/osv-scanner_linux_amd64"
|
||||
chmod +x "$HOME/.local/bin/osv-scanner"
|
||||
|
||||
- name: Run OSV-Scanner
|
||||
run: |
|
||||
set +e
|
||||
osv-scanner scan source --recursive --format json --output-file osv-results.json .
|
||||
echo "OSV_EXIT=$?" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Ergebnisse & Gitea-Issue erstellen/aktualisieren
|
||||
env:
|
||||
GITEA_URL: ${{ gitea.server_url || github.server_url }}
|
||||
REPO: ${{ gitea.repository || github.repository }}
|
||||
TOKEN: ${{ secrets.SECURITY_TOKEN }}
|
||||
RUN_URL: ${{ gitea.server_url || github.server_url }}/${{ gitea.repository || github.repository }}/actions/runs/${{ gitea.run_id || github.run_id }}
|
||||
run: |
|
||||
python3 scripts/report-security-issue.py trivy-results.json osv-results.json
|
||||
@@ -0,0 +1,111 @@
|
||||
name: Auto-PR (Testing → Main)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- testing
|
||||
|
||||
jobs:
|
||||
create-pr:
|
||||
name: Erstelle automatisch PR von testing nach main
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Prüfe auf bereits offenen PR nach main
|
||||
id: check_pr
|
||||
env:
|
||||
GITEA_URL: ${{ gitea.server_url || github.server_url }}
|
||||
REPO: ${{ gitea.repository || github.repository }}
|
||||
TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }}
|
||||
run: |
|
||||
OPEN_PRS=$(curl -s -H "Authorization: token ${TOKEN}" "${GITEA_URL}/api/v1/repos/${REPO}/pulls?state=open&limit=50")
|
||||
EXISTS=$(echo "$OPEN_PRS" | jq -r '[.[] | select(.base.ref == "main" and .head.ref == "testing")] | length')
|
||||
echo "Bereits offene testing→main PRs: ${EXISTS}"
|
||||
echo "exists=${EXISTS}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Ermittle Versionen auf main & testing
|
||||
id: versions
|
||||
if: steps.check_pr.outputs.exists == '0'
|
||||
run: |
|
||||
git fetch origin main
|
||||
MAIN_VERSION="$(git show origin/main:Cargo.toml | sed -n 's/^version = "\(.*\)"/\1/p' | head -n1)"
|
||||
TESTING_VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
|
||||
echo "Version auf main: ${MAIN_VERSION} / Version auf testing: ${TESTING_VERSION}"
|
||||
echo "main_version=${MAIN_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "testing_version=${TESTING_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Ermittle geänderte Kategorien (main...testing)
|
||||
id: categories
|
||||
if: steps.check_pr.outputs.exists == '0' && steps.versions.outputs.main_version == steps.versions.outputs.testing_version
|
||||
run: |
|
||||
CHANGED_FILES="$(git diff --name-only origin/main...HEAD)"
|
||||
echo "Geänderte Dateien main...testing:"
|
||||
echo "$CHANGED_FILES"
|
||||
|
||||
WORKFLOWS="false"
|
||||
CONFIG="false"
|
||||
DOCS="false"
|
||||
|
||||
if echo "$CHANGED_FILES" | grep -q '^\.gitea/workflows/'; then
|
||||
WORKFLOWS="true"
|
||||
fi
|
||||
if echo "$CHANGED_FILES" | grep -qE '^(renovate\.json|qodana\.yaml|Cargo\.toml|Cargo\.lock|\.cargo/)'; then
|
||||
CONFIG="true"
|
||||
fi
|
||||
if echo "$CHANGED_FILES" | grep -qE '(^|/)[^/]+\.md$|^LICENSE$'; then
|
||||
DOCS="true"
|
||||
fi
|
||||
|
||||
echo "workflows=${WORKFLOWS}" >> "$GITHUB_OUTPUT"
|
||||
echo "config=${CONFIG}" >> "$GITHUB_OUTPUT"
|
||||
echo "docs=${DOCS}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Bestimme PR-Titel
|
||||
id: title
|
||||
if: steps.check_pr.outputs.exists == '0'
|
||||
run: |
|
||||
if [ "${{ steps.versions.outputs.main_version }}" != "${{ steps.versions.outputs.testing_version }}" ]; then
|
||||
TITLE="Merge testing in main: Release ${{ steps.versions.outputs.testing_version }}"
|
||||
else
|
||||
PARTS=()
|
||||
[ "${{ steps.categories.outputs.workflows }}" = "true" ] && PARTS+=("Workflows")
|
||||
[ "${{ steps.categories.outputs.config }}" = "true" ] && PARTS+=("Konfigurationen")
|
||||
[ "${{ steps.categories.outputs.docs }}" = "true" ] && PARTS+=("Dokumentation")
|
||||
|
||||
if [ ${#PARTS[@]} -eq 0 ]; then
|
||||
TITLE="Merge testing in main"
|
||||
else
|
||||
JOINED=""
|
||||
for PART in "${PARTS[@]}"; do
|
||||
if [ -z "$JOINED" ]; then
|
||||
JOINED="$PART"
|
||||
else
|
||||
JOINED="${JOINED} & ${PART}"
|
||||
fi
|
||||
done
|
||||
TITLE="Merge testing in main: ${JOINED} aktualisiert"
|
||||
fi
|
||||
fi
|
||||
echo "Ermittelter PR-Titel: ${TITLE}"
|
||||
echo "title=${TITLE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Erstelle PR (testing -> main)
|
||||
if: steps.check_pr.outputs.exists == '0'
|
||||
env:
|
||||
GITEA_URL: ${{ gitea.server_url || github.server_url }}
|
||||
REPO: ${{ gitea.repository || github.repository }}
|
||||
TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }}
|
||||
TITLE: ${{ steps.title.outputs.title }}
|
||||
run: |
|
||||
PAYLOAD=$(jq -n --arg title "$TITLE" --arg head "testing" --arg base "main" \
|
||||
'{title: $title, head: $head, base: $base}')
|
||||
curl -f -s -S -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/pulls"
|
||||
echo "PR erstellt: ${TITLE}"
|
||||
@@ -0,0 +1,249 @@
|
||||
name: Testing Build, Publish & Preview Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- testing
|
||||
|
||||
jobs:
|
||||
detect-changes:
|
||||
name: Erkenne relevante Code-Änderungen
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
code_changed: ${{ steps.filter.outputs.code }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Prüfe auf Änderungen am Programmcode
|
||||
uses: dorny/paths-filter@v4
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
code:
|
||||
- 'src/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'scripts/**'
|
||||
- '.cargo/**'
|
||||
- '.gitea/workflows/testing.yaml'
|
||||
|
||||
build-and-publish:
|
||||
name: Build, Publish Packages (Testing) & Create Preview Release
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.code_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CARGO_BINSTALL_VERSION: "1.23.0"
|
||||
CARGO_DEB_VERSION: "3.8.0"
|
||||
CARGO_GENERATE_RPM_VERSION: "0.21.0"
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Rust Toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v2
|
||||
with:
|
||||
toolchain: stable
|
||||
cache: false
|
||||
|
||||
- name: Cache Cargo-Abhängigkeiten & Build-Artefakte
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: |
|
||||
cargo-${{ runner.os }}-
|
||||
|
||||
- name: Alte Paketierungs-Ausgaben aus dem Cache entfernen
|
||||
run: rm -rf target/debian target/generate-rpm target/arch
|
||||
|
||||
- name: Install Cross-Compilation Toolchains (apt)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||
|
||||
- name: Ermittle Rust-Version für Rustup-Target-Cache-Key
|
||||
run: echo "RUST_VERSION=$(rustc --version | awk '{print $2}')" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Cache Rustup Cross-Compilation-Targets
|
||||
id: cache-rustup-targets
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/aarch64-unknown-linux-gnu
|
||||
key: rustup-targets-${{ runner.os }}-${{ env.RUST_VERSION }}
|
||||
|
||||
- name: Add Rust Cross-Compilation Targets
|
||||
if: steps.cache-rustup-targets.outputs.cache-hit != 'true'
|
||||
run: rustup target add aarch64-unknown-linux-gnu
|
||||
|
||||
- name: PATH um Cargo-bin-Verzeichnis ergänzen
|
||||
run: |
|
||||
mkdir -p ~/.cargo/bin
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Cache Packaging-Tools (cargo-binstall, cargo-deb, cargo-generate-rpm)
|
||||
id: cache-packaging-tools
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/cargo-binstall
|
||||
~/.cargo/bin/cargo-deb
|
||||
~/.cargo/bin/cargo-generate-rpm
|
||||
key: packaging-tools-${{ runner.os }}-${{ env.CARGO_BINSTALL_VERSION }}-${{ env.CARGO_DEB_VERSION }}-${{ env.CARGO_GENERATE_RPM_VERSION }}
|
||||
|
||||
- name: Install Packaging Tools (Prebuilt Binaries)
|
||||
if: steps.cache-packaging-tools.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
curl -fsSL "https://github.com/cargo-bins/cargo-binstall/releases/download/v${CARGO_BINSTALL_VERSION}/cargo-binstall-x86_64-unknown-linux-musl.tgz" | tar -xz -C ~/.cargo/bin
|
||||
~/.cargo/bin/cargo-binstall -y --no-symlinks "cargo-deb@${CARGO_DEB_VERSION}" "cargo-generate-rpm@${CARGO_GENERATE_RPM_VERSION}"
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
cargo test
|
||||
|
||||
- name: Build Release Binaries
|
||||
run: |
|
||||
cargo build --release --target x86_64-unknown-linux-gnu
|
||||
cargo build --release --target aarch64-unknown-linux-gnu
|
||||
|
||||
- name: Determine Build Number
|
||||
id: build_num
|
||||
env:
|
||||
GITEA_URL: ${{ gitea.server_url || github.server_url }}
|
||||
REPO: ${{ gitea.repository || github.repository }}
|
||||
REPO_OWNER: ${{ gitea.repository_owner || github.repository_owner }}
|
||||
TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }}
|
||||
run: |
|
||||
BUILD_NUM=$(python3 scripts/get-build-number.py)
|
||||
echo "build_number=${BUILD_NUM}" >> $GITHUB_OUTPUT
|
||||
echo "BUILD_NUMBER=${BUILD_NUM}" >> $GITHUB_ENV
|
||||
echo "Ermittelte Build-Nummer: ${BUILD_NUM}"
|
||||
|
||||
- name: Build Debian Packages (.deb)
|
||||
run: |
|
||||
cargo deb --target x86_64-unknown-linux-gnu --deb-revision "${BUILD_NUMBER}" --no-build
|
||||
cargo deb --target aarch64-unknown-linux-gnu --deb-revision "${BUILD_NUMBER}" --no-build
|
||||
|
||||
- name: Build Fedora / RPM Packages (.rpm)
|
||||
run: |
|
||||
mkdir -p target/generate-rpm
|
||||
cargo generate-rpm --target x86_64-unknown-linux-gnu -s "release=\"${BUILD_NUMBER}\"" -o target/generate-rpm
|
||||
cargo generate-rpm --target aarch64-unknown-linux-gnu -s "release=\"${BUILD_NUMBER}\"" -o target/generate-rpm
|
||||
|
||||
- name: Build Arch Linux Packages (.pkg.tar.zst)
|
||||
run: |
|
||||
python3 scripts/package-arch.py --target x86_64-unknown-linux-gnu --pkgrel "${BUILD_NUMBER}"
|
||||
python3 scripts/package-arch.py --target aarch64-unknown-linux-gnu --pkgrel "${BUILD_NUMBER}"
|
||||
|
||||
- name: Publish Packages to Gitea Package Registry
|
||||
env:
|
||||
GITEA_URL: ${{ gitea.server_url || github.server_url }}
|
||||
REPO_OWNER: ${{ gitea.repository_owner || github.repository_owner }}
|
||||
TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }}
|
||||
run: |
|
||||
echo "Veröffentliche Debian-Paket (Distribution: testing, Component: main)..."
|
||||
for deb in target/debian/*.deb; do
|
||||
[ -f "$deb" ] || continue
|
||||
curl -f -s -S -X PUT \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
--upload-file "$deb" \
|
||||
"${GITEA_URL}/api/packages/${REPO_OWNER}/debian/pool/testing/main/upload"
|
||||
done
|
||||
|
||||
echo "Veröffentliche Fedora/RPM-Paket (Gruppe: testing)..."
|
||||
for rpm in target/generate-rpm/*.rpm; do
|
||||
[ -f "$rpm" ] || continue
|
||||
curl -f -s -S -X PUT \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
--upload-file "$rpm" \
|
||||
"${GITEA_URL}/api/packages/${REPO_OWNER}/rpm/testing/upload"
|
||||
done
|
||||
|
||||
echo "Veröffentliche Arch Linux-Paket (Repository: testing)..."
|
||||
for pkg in target/arch/*.pkg.tar.zst; do
|
||||
[ -f "$pkg" ] || continue
|
||||
curl -f -s -S -X PUT \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
--upload-file "$pkg" \
|
||||
"${GITEA_URL}/api/packages/${REPO_OWNER}/arch/testing"
|
||||
done
|
||||
|
||||
- name: Create Gitea Pre-Release and Upload Assets
|
||||
env:
|
||||
GITEA_URL: ${{ gitea.server_url || github.server_url }}
|
||||
REPO: ${{ gitea.repository || github.repository }}
|
||||
REPO_NAME: ${{ gitea.repository_name || github.event.repository.name }}
|
||||
TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }}
|
||||
run: |
|
||||
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
|
||||
TAG_NAME="v${VERSION}-preview"
|
||||
RELEASE_TITLE="Preview Release ${TAG_NAME}"
|
||||
RELEASE_NOTES="Automatisches Preview-Release für ${REPO_NAME} ${VERSION} (Branch: Testing)."
|
||||
|
||||
echo "Erstelle oder hole Preview-Release für Tag ${TAG_NAME} in ${REPO}..."
|
||||
|
||||
GET_RESP=$(curl -s -w "\n%{http_code}" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG_NAME}")
|
||||
HTTP_CODE=$(echo "$GET_RESP" | tail -n1)
|
||||
BODY=$(echo "$GET_RESP" | sed '$d')
|
||||
|
||||
RELEASE_ID=""
|
||||
if [ "$HTTP_CODE" -eq 200 ]; then
|
||||
RELEASE_ID=$(echo "$BODY" | jq -r '.id // empty' 2>/dev/null || echo "$BODY" | grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2)
|
||||
echo "Bestehendes Release gefunden (ID: ${RELEASE_ID})."
|
||||
else
|
||||
echo "Erstelle neues Preview-Release ${TAG_NAME}..."
|
||||
CREATE_PAYLOAD=$(cat <<EOF
|
||||
{
|
||||
"tag_name": "${TAG_NAME}",
|
||||
"target_commitish": "testing",
|
||||
"name": "${RELEASE_TITLE}",
|
||||
"body": "${RELEASE_NOTES}",
|
||||
"draft": false,
|
||||
"prerelease": true
|
||||
}
|
||||
EOF
|
||||
)
|
||||
CREATE_RESP=$(curl -f -s -S -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$CREATE_PAYLOAD" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases")
|
||||
RELEASE_ID=$(echo "$CREATE_RESP" | jq -r '.id // empty' 2>/dev/null || echo "$CREATE_RESP" | grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2)
|
||||
echo "Neues Preview-Release erstellt (ID: ${RELEASE_ID})."
|
||||
fi
|
||||
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
echo "Fehler: Release-ID konnte nicht ermittelt werden!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
EXISTING_ASSETS_JSON=$(curl -s \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" || echo "[]")
|
||||
|
||||
for file in target/debian/*.deb target/generate-rpm/*.rpm target/arch/*.pkg.tar.zst; do
|
||||
[ -f "$file" ] || continue
|
||||
filename="$(basename "$file")"
|
||||
echo "Lade Release-Asset hoch: $filename"
|
||||
|
||||
ASSET_ID=$(echo "$EXISTING_ASSETS_JSON" | jq -r --arg name "$filename" '.[]? | select(.name == $name) | .id' 2>/dev/null | head -n1 || true)
|
||||
if [ -n "$ASSET_ID" ] && [ "$ASSET_ID" != "null" ]; then
|
||||
echo "Lösche altes Asset mit ID ${ASSET_ID}..."
|
||||
curl -s -X DELETE \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}" || true
|
||||
fi
|
||||
|
||||
curl -f -s -S -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-F "attachment=@${file}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
||||
echo "Asset ${filename} erfolgreich hochgeladen."
|
||||
done
|
||||
@@ -0,0 +1,59 @@
|
||||
name: TruffleHog Secret Scan
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "0 6 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
trufflehog-scan:
|
||||
name: TruffleHog
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TRUFFLEHOG_VERSION: "3.97.4"
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Lokales bin-Verzeichnis zum PATH hinzufügen
|
||||
run: |
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Cache TruffleHog-Binary
|
||||
id: cache-trufflehog
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.local/bin/trufflehog
|
||||
key: trufflehog-bin-${{ runner.os }}-${{ env.TRUFFLEHOG_VERSION }}
|
||||
|
||||
- name: Install TruffleHog
|
||||
if: steps.cache-trufflehog.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
curl -fsSL -o trufflehog.tar.gz \
|
||||
"https://github.com/trufflesecurity/trufflehog/releases/download/v${TRUFFLEHOG_VERSION}/trufflehog_${TRUFFLEHOG_VERSION}_linux_amd64.tar.gz"
|
||||
tar -xzf trufflehog.tar.gz trufflehog
|
||||
chmod +x trufflehog
|
||||
mv trufflehog "$HOME/.local/bin/trufflehog"
|
||||
rm trufflehog.tar.gz
|
||||
|
||||
- name: Run TruffleHog Scanner
|
||||
run: |
|
||||
set +e
|
||||
trufflehog git file://. --results=verified,unknown --fail --json > trufflehog-results.json
|
||||
echo "TRUFFLEHOG_EXIT=$?" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Ergebnisse & Gitea-Issue erstellen/aktualisieren
|
||||
env:
|
||||
GITEA_URL: ${{ gitea.server_url || github.server_url }}
|
||||
REPO: ${{ gitea.repository || github.repository }}
|
||||
TOKEN: ${{ secrets.SECURITY_TOKEN }}
|
||||
RUN_URL: ${{ gitea.server_url || github.server_url }}/${{ gitea.repository || github.repository }}/actions/runs/${{ gitea.run_id || github.run_id }}
|
||||
ISSUE_TITLE: "Security-Scan: TruffleHog Secrets"
|
||||
ISSUE_LABEL: "security-scan-trufflehog"
|
||||
run: |
|
||||
python3 scripts/report-security-issue.py "" "" trufflehog-results.json
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Unit-Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
branches:
|
||||
- testing
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Unit-Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Rust Toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v2
|
||||
with:
|
||||
toolchain: stable
|
||||
cache: false
|
||||
|
||||
- name: Cache Cargo-Abhängigkeiten & Build-Artefakte
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: |
|
||||
cargo-${{ runner.os }}-
|
||||
|
||||
- name: Run Tests
|
||||
run: cargo test
|
||||
@@ -0,0 +1,84 @@
|
||||
name: Auto Patch-Version-Bump (Dev → Testing PR)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened]
|
||||
branches:
|
||||
- testing
|
||||
|
||||
jobs:
|
||||
detect-changes:
|
||||
name: Erkenne relevante Änderungen im PR
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ (gitea.head_ref || github.head_ref) == 'dev' }}
|
||||
outputs:
|
||||
code_changed: ${{ steps.filter.outputs.code }}
|
||||
steps:
|
||||
- name: Checkout Dev-Branch (PR-Head)
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ gitea.head_ref || github.head_ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Prüfe auf Änderungen an Cargo.toml, Cargo.lock oder src/
|
||||
uses: dorny/paths-filter@v4
|
||||
id: filter
|
||||
with:
|
||||
base: ${{ gitea.base_ref || github.base_ref }}
|
||||
filters: |
|
||||
code:
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'src/**'
|
||||
|
||||
bump-version:
|
||||
name: Patch-Version erhöhen & auf Dev pushen
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.code_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Dev-Branch (PR-Head)
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ gitea.head_ref || github.head_ref }}
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }}
|
||||
|
||||
- name: Ermittle Cargo-Version auf testing & dev
|
||||
id: versions
|
||||
run: |
|
||||
git fetch origin testing --depth=1
|
||||
TESTING_VERSION="$(git show origin/testing:Cargo.toml | sed -n 's/^version = "\(.*\)"/\1/p' | head -n1)"
|
||||
DEV_VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
|
||||
echo "Version auf testing: ${TESTING_VERSION} / Version auf dev: ${DEV_VERSION}"
|
||||
echo "testing_version=${TESTING_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "dev_version=${DEV_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Patch-Version um 1 erhöhen (Cargo.toml & Cargo.lock)
|
||||
if: steps.versions.outputs.testing_version == steps.versions.outputs.dev_version
|
||||
run: |
|
||||
VERSION="${{ steps.versions.outputs.dev_version }}"
|
||||
MAJOR="$(echo "$VERSION" | cut -d. -f1)"
|
||||
MINOR="$(echo "$VERSION" | cut -d. -f2)"
|
||||
PATCH="$(echo "$VERSION" | cut -d. -f3)"
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||
echo "Erhöhe Version: ${VERSION} -> ${NEW_VERSION}"
|
||||
|
||||
sed -i "0,/^version = \"${VERSION}\"/s//version = \"${NEW_VERSION}\"/" Cargo.toml
|
||||
|
||||
PACKAGE_NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
|
||||
awk -v new="$NEW_VERSION" -v pkg="$PACKAGE_NAME" '
|
||||
found_name && /^version = "/ {
|
||||
print "version = \"" new "\""
|
||||
found_name = 0
|
||||
next
|
||||
}
|
||||
$0 == "name = \"" pkg "\"" { found_name = 1 }
|
||||
{ print }
|
||||
' Cargo.lock > Cargo.lock.tmp && mv Cargo.lock.tmp Cargo.lock
|
||||
|
||||
git config user.name "Gitea-Bot"
|
||||
git config user.email "no-reply@creativedragonslayer.de"
|
||||
git add Cargo.toml Cargo.lock
|
||||
git commit -m "Chore: Erhöht Patch-Version auf ${NEW_VERSION} für Promotion nach testing"
|
||||
git push origin HEAD:${{ gitea.head_ref || github.head_ref }}
|
||||
+1
-401
@@ -4,10 +4,6 @@
|
||||
debug/
|
||||
target/
|
||||
|
||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||
Cargo.lock
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
@@ -113,400 +109,4 @@ fabric.properties
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
# ---> VisualStudio
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Mono auto generated files
|
||||
mono_crash.*
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# Visual Studio 2017 auto generated files
|
||||
Generated\ Files/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUnit
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# Benchmark Results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# .NET Core
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
# but not Directory.Build.rsp, as it configures directory-level build defaults
|
||||
!Directory.Build.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.log
|
||||
*.tlog
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# Visual Studio Trace Files
|
||||
*.e2e
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Coverlet is a free, cross platform Code Coverage Tool
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
coverage*.info
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these scripts will be unencrypted
|
||||
PublishScripts/
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# NuGet Symbol Packages
|
||||
*.snupkg
|
||||
# The packages folder can be ignored because of Package Restore
|
||||
**/[Pp]ackages/*
|
||||
# except build/, which is used as an MSBuild target.
|
||||
!**/[Pp]ackages/build/
|
||||
# Uncomment if necessary however generally it will be regenerated when needed
|
||||
#!**/[Pp]ackages/repositories.config
|
||||
# NuGet v3's project.json files produces more ignorable files
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!?*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
# Including strong name files can present a security risk
|
||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||
#*.snk
|
||||
|
||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
ServiceFabricBackup/
|
||||
*.rptproj.bak
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
*- [Bb]ackup.rdl
|
||||
*- [Bb]ackup ([0-9]).rdl
|
||||
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||
*.vbw
|
||||
|
||||
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
|
||||
*.vbp
|
||||
|
||||
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
||||
*.dsw
|
||||
*.dsp
|
||||
|
||||
# Visual Studio 6 technical files
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# CodeRush personal settings
|
||||
.cr/personal
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Cake - Uncomment if you are using it
|
||||
# tools/**
|
||||
# !tools/packages.config
|
||||
|
||||
# Tabs Studio
|
||||
*.tss
|
||||
|
||||
# Telerik's JustMock configuration file
|
||||
*.jmconfig
|
||||
|
||||
# BizTalk build output
|
||||
*.btp.cs
|
||||
*.btm.cs
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
|
||||
# OpenCover UI analysis results
|
||||
OpenCover/
|
||||
|
||||
# Azure Stream Analytics local run output
|
||||
ASALocalRun/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
# NVidia Nsight GPU debugger configuration file
|
||||
*.nvuser
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
# Local History for Visual Studio
|
||||
.localhistory/
|
||||
|
||||
# Visual Studio History (VSHistory) files
|
||||
.vshistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
|
||||
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||
MigrationBackup/
|
||||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
|
||||
# VS Code files for those working on multiple tools
|
||||
*.code-workspace
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
|
||||
# Windows Installer files from build outputs
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# JetBrains Rider
|
||||
*.sln.iml
|
||||
.idea
|
||||
|
||||
# Added by cargo
|
||||
|
||||
/target
|
||||
.junie/plans
|
||||
|
||||
Generated
+1
@@ -3,6 +3,7 @@
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
|
||||
Generated
+65
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="JsonSchemaMappingsProjectConfiguration">
|
||||
<state>
|
||||
<map>
|
||||
<entry key="Cargo Config">
|
||||
<value>
|
||||
<SchemaInfo>
|
||||
<option name="name" value="Cargo Config" />
|
||||
<option name="relativePathToSchema" value="https://www.schemastore.org/cargo-config.json" />
|
||||
<option name="applicationDefined" value="true" />
|
||||
<option name="patterns">
|
||||
<list>
|
||||
<Item>
|
||||
<option name="path" value=".cargo/config.toml" />
|
||||
</Item>
|
||||
</list>
|
||||
</option>
|
||||
</SchemaInfo>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="GitHub Workflow">
|
||||
<value>
|
||||
<SchemaInfo>
|
||||
<option name="name" value="GitHub Workflow" />
|
||||
<option name="relativePathToSchema" value="https://www.schemastore.org/github-workflow.json" />
|
||||
<option name="applicationDefined" value="true" />
|
||||
<option name="patterns">
|
||||
<list>
|
||||
<Item>
|
||||
<option name="path" value=".gitea/workflows/code-quality.yaml" />
|
||||
</Item>
|
||||
<Item>
|
||||
<option name="path" value=".gitea/workflows/version-bump.yaml" />
|
||||
</Item>
|
||||
<Item>
|
||||
<option name="path" value=".gitea/workflows/unit-tests.yaml" />
|
||||
</Item>
|
||||
<Item>
|
||||
<option name="path" value=".gitea/workflows/trufflehog-scan.yaml" />
|
||||
</Item>
|
||||
<Item>
|
||||
<option name="path" value=".gitea/workflows/testing-to-main-pr.yaml" />
|
||||
</Item>
|
||||
<Item>
|
||||
<option name="path" value=".gitea/workflows/testing.yaml" />
|
||||
</Item>
|
||||
<Item>
|
||||
<option name="path" value=".gitea/workflows/security-scan.yaml" />
|
||||
</Item>
|
||||
<Item>
|
||||
<option name="path" value=".gitea/workflows/renovate.yaml" />
|
||||
</Item>
|
||||
<Item>
|
||||
<option name="path" value=".gitea/workflows/main.yaml" />
|
||||
</Item>
|
||||
</list>
|
||||
</option>
|
||||
</SchemaInfo>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</state>
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,138 @@
|
||||
# AGENTS.md
|
||||
|
||||
Dieses Dokument dient als technischer Leitfaden und Kontextdokument für KI-Coding-Agenten (sowie Entwickler), die an diesem Repository oder daraus abgeleiteten Projekten arbeiten.
|
||||
|
||||
---
|
||||
|
||||
## 1. Projektübersicht & Philosophie
|
||||
|
||||
Dieses Repository ist ein **Rust-Projekt-Template** für Linux-Anwendungen und CLI-Tools mit Fokus auf:
|
||||
- Automatisierte Multi-Architektur-Kompilierung (`x86_64`, `aarch64`).
|
||||
- Native Paketierung für Debian (`.deb`), Fedora/RHEL (`.rpm`) und Arch Linux (`.pkg.tar.zst`) sowie Docker-Container-Images.
|
||||
- Vollständig automatisierte CI/CD-Pipelines via Gitea Actions (kompatibel mit Forgejo / GitHub Actions).
|
||||
- Automatisierte Sicherheits-Scans (Schwachstellen, Secrets) und Dependency-Updates.
|
||||
|
||||
### Standards & Tech-Stack
|
||||
- **Sprache**: Rust (Edition 2024), Python 3 (für Hilfsskripte in `scripts/`).
|
||||
- **Rust Toolchain**: Stable.
|
||||
- **Zielplattform**: Linux (GLIBC-basiert, Cross-Kompilierung für `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`).
|
||||
- **Container**: Docker-Images werden zusätzlich zu den nativen Paketen gebaut und in die Gitea Container Registry veröffentlicht.
|
||||
- **Sicherheits-Tooling**: Trivy, OSV-Scanner, TruffleHog (Secret-Scanning), Renovate (Dependency-Updates), Qodana (statische Codeanalyse).
|
||||
- **Lizenz**: GPL-3.0-or-later (sofern nicht im abgeleiteten Projekt anders definiert).
|
||||
|
||||
---
|
||||
|
||||
## 2. Projektstruktur
|
||||
|
||||
```text
|
||||
├── .cargo/
|
||||
│ └── config.toml # Linker für Cross-Target-Kompilierung & Registry-Konfiguration
|
||||
├── .gitea/
|
||||
│ └── workflows/
|
||||
│ ├── main.yaml # CI/CD: Stabile Builds, Multi-Arch-Paketierung, Docker-Image, Release & Upload
|
||||
│ ├── testing.yaml # CI/CD: Preview-Builds, Docker-Image & Testing-Pakete
|
||||
│ ├── unit-tests.yaml # CI: Unit-Tests für Pull Requests gegen 'testing'
|
||||
│ ├── security-scan.yaml # CI: Trivy & OSV-Scanner (Schwachstellen/Misconfig/Secrets)
|
||||
│ ├── trufflehog-scan.yaml # CI: TruffleHog Secret-Scan (inkl. Git-Historie)
|
||||
│ └── renovate.yaml # CI: Wöchentlicher Renovate-Lauf für Dependency-Updates
|
||||
├── scripts/
|
||||
│ ├── get-build-number.py # Ermittelt automatisch die nächste Revisions-/Build-Nummer
|
||||
│ ├── package-arch.py # Erzeugt native Arch Linux .pkg.tar.zst Pakete
|
||||
│ └── report-security-issue.py # Meldet Scan-Ergebnisse (Trivy/OSV/TruffleHog) als Gitea-Issue
|
||||
├── src/
|
||||
│ └── main.rs # Einstiegspunkt der Anwendung
|
||||
├── Cargo.toml # Projekt-Manifest & Metadaten für deb, rpm und arch
|
||||
├── qodana.yaml # Konfiguration für JetBrains Qodana (statische Analyse)
|
||||
├── renovate.json # Renovate-Konfiguration (Gruppierung, Versions-Pins in Workflows)
|
||||
├── LICENSE # Lizenztext
|
||||
├── README.md # Benutzerdokumentation & Setup-Checkliste
|
||||
└── AGENTS.md # Dieses Agenten-Handbuch
|
||||
```
|
||||
|
||||
> **Hinweis:** `main.yaml`/`testing.yaml` bauen zusätzlich ein Docker-Image (`docker build .` mit `--build-arg TARGET_BIN=...`). Ein `Dockerfile` ist im Template noch **nicht** enthalten und muss von abgeleiteten Projekten ergänzt werden; der aktuell hartkodierte `TARGET_BIN`-Pfad (`.../release/mirror-package`) ist ein Platzhalter aus einem Referenzprojekt und muss beim Ableiten des Templates auf den tatsächlichen Binärnamen (`Cargo.toml` → `[package] name`) angepasst werden.
|
||||
|
||||
---
|
||||
|
||||
## 3. Regeln & Richtlinien für Agenten
|
||||
|
||||
### 3.1 Code-Stil & Best Practices
|
||||
- **Idiomatisches Rust**: Nutze moderne Sprachfeatures der Rust Edition 2024.
|
||||
- **Fehlerbehandlung**: Verwende aussagekräftige Fehlertypen (z. B. mit `thiserror` oder `anyhow` für CLIs). Vermeide unnötiges `unwrap()` oder `panic!()` im Produktivcode.
|
||||
- **Kommentare**: Ergänze KDoc/RustDoc-Kommentare (`///`) an öffentlichen Funktionen und Typen. Behalte die bestehende Sprachkonvention bei.
|
||||
- **Template-TODOs**: Wenn neue Vorlagen-Features oder Platzhalter ergänzt werden, markiere anpassungsbedürftige Stellen eindeutig mit `// TODO:` (Rust), `# TODO:` (TOML/Python/YAML).
|
||||
|
||||
### 3.2 Paketierungs-Metadaten in `Cargo.toml`
|
||||
Bei Änderungen an Binärnamen, Abhängigkeiten oder Beschreibungen müssen die drei Metadaten-Blöcke in `Cargo.toml` synchron gehalten werden:
|
||||
1. `[package.metadata.deb]` (für `cargo-deb`):
|
||||
- `maintainer`, `copyright`, `section`, `priority`, `depends`, `extended-description`, `assets`.
|
||||
2. `[package.metadata.generate-rpm]` (für `cargo-generate-rpm`):
|
||||
- `requires`, `assets`.
|
||||
3. `[package.metadata.arch]` (für `scripts/package-arch.py`):
|
||||
- `pkgrel`, `arch`, `depends`, `optdepends`.
|
||||
|
||||
Ändert sich der Binärname (`[package] name`), muss auch der `TARGET_BIN`-Build-Arg im Docker-Build-Step von `main.yaml`/`testing.yaml` sowie das (abzuleitende) `Dockerfile` angepasst werden.
|
||||
|
||||
### 3.3 Skripte in `scripts/`
|
||||
- **Generizität**: Die Skripte dürfen keine hardcodierten Anwendungsnamen, spezifischen Abhängigkeiten oder projektspezifischen URLs enthalten. Alle Werte müssen dynamisch aus `Cargo.toml` (via `cargo metadata` oder Dateiparsing) oder Umgebungsvariablen (`BUILD_NUMBER`, `GITEA_URL`, `REPO`, `TOKEN`) ermittelt werden.
|
||||
- **Python-Kompatibilität**: Verwende Standard-Python 3 ohne externe PyPI-Abhängigkeiten (nur Standardbibliothek: `json`, `subprocess`, `urllib`, `argparse`, `os`, `re`, `tempfile`, `tarfile` etc.).
|
||||
- **`get-build-number.py`**: Ermittelt die nächste Build-/Revisions-Nummer nicht mehr rein lokal, sondern dynamisch über:
|
||||
1. Gitea Releases API (Tag-/Asset-Namen),
|
||||
2. Gitea Packages API (jeweils neueste Version pro Paket-Typ: `debian`, `rpm`, `arch`),
|
||||
3. lokale `target/{debian,generate-rpm,arch}`-Verzeichnisse als Fallback.
|
||||
Unterstützt CLI-Flags (`--version`, `--gitea-url`, `--repo`, `--owner`, `--token`, `--build-number`) sowie Umgebungsvariablen-Fallbacks (`BUILD_NUMBER`/`BUILD_NUM`/`PKGREL`, `GITEA_URL`, `REPO`, `REPO_OWNER`, `TOKEN`). Package-Typen (deb/rpm/arch) teilen sich eine gemeinsame Build-Nummer.
|
||||
- **`report-security-issue.py`**: Fasst Funde aus Trivy-, OSV-Scanner- und TruffleHog-JSON-Reports zusammen, sortiert nach Schweregrad und pflegt darüber ein einzelnes offenes Gitea-Issue pro Scan-Typ (Kommentar-Historie statt ständig neuer Issues; Label per `ISSUE_LABEL`, Titel per `ISSUE_TITLE` konfigurierbar). Secret-Werte selbst werden nie ausgegeben.
|
||||
|
||||
---
|
||||
|
||||
## 4. Workflows: Bauen, Testen & Validieren
|
||||
|
||||
Agenten müssen Änderungen vor dem Abschluss validieren.
|
||||
|
||||
### 4.1 Grundlegende Validierung
|
||||
```bash
|
||||
# Syntax- und Typprüfung
|
||||
cargo check
|
||||
|
||||
# Unit- & Integrationstests
|
||||
cargo test
|
||||
|
||||
# Release-Build prüfen
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
### 4.2 Hilfsskripte testen
|
||||
```bash
|
||||
# Build-Nummern-Skript testen (rein lokal, ohne Gitea-API)
|
||||
python3 scripts/get-build-number.py
|
||||
|
||||
# Arch Linux Paketierung lokal testen (nach 'cargo build --release')
|
||||
python3 scripts/package-arch.py --arch x86_64 --pkgrel 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. CI/CD-Pipeline Details
|
||||
|
||||
Alle Workflows liegen unter `.gitea/workflows/` und nutzen gecachte Abhängigkeiten (`actions/cache@v6` für Cargo-Registry/Build-Artefakte, Rustup-Targets und Packaging-Tools) sowie fest gepinnte Tool-Versionen über `env`-Variablen — diese werden von Renovate automatisch aktuell gehalten (siehe 5.2).
|
||||
|
||||
### 5.1 Build & Release
|
||||
- **`main.yaml`** (Trigger: `push` auf `main`): Baut Binaries für alle 3 Architekturen, führt `cargo test` aus, baut `.deb`, `.rpm` und `.pkg.tar.zst`, lädt sie in die Gitea Package Registry hoch, erstellt ein Gitea Release `v<VERSION>` und baut/veröffentlicht zusätzlich ein Docker-Image (`:latest`, `:<VERSION>`, `:v<VERSION>`, `:<VERSION>.<BUILD_NUMBER>`).
|
||||
- **`testing.yaml`** (Trigger: `push` auf `testing`): Analog zu `main.yaml`, veröffentlicht jedoch in die `testing`-Kanäle der Paket-Registry, erstellt ein Pre-Release `v<VERSION>-preview` und taggt Docker-Images mit `:testing`, `:<VERSION>-preview`, `:<VERSION>-testing` etc.
|
||||
- **`unit-tests.yaml`** (Trigger: `pull_request` → `testing`, bei `opened`/`synchronize`/`reopened`): Schnelle CI-Prüfung (`cargo test`) für Pull Requests, ohne Paketierung oder Veröffentlichung.
|
||||
|
||||
### 5.2 Sicherheit & Qualität
|
||||
- **`security-scan.yaml`** (Trigger: `push` auf `main`/`testing`/`dev`, `pull_request`, wöchentlich montags 05:00 UTC, `workflow_dispatch`): Führt Trivy (`vuln`, `secret`, `misconfig`; Schweregrad `CRITICAL`/`HIGH`) und OSV-Scanner aus und meldet Funde über `scripts/report-security-issue.py` als Gitea-Issue (Label `security-scan`).
|
||||
- **`trufflehog-scan.yaml`** (Trigger: `push`, `pull_request`, wöchentlich montags 06:00 UTC, `workflow_dispatch`): Durchsucht die volle Git-Historie (`fetch-depth: 0`) nach verifizierten/unbekannten Secrets und meldet Funde in ein eigenes Issue (Label `security-scan-trufflehog`) inkl. Rotations-Anleitung.
|
||||
- **`qodana.yaml`**: Konfiguration für JetBrains Qodana (statische Codeanalyse, Profil `qodana.starter`); wird über eine externe/IDE-seitige Qodana-CI-Integration ausgeführt, nicht über einen eigenen Gitea-Workflow in diesem Template.
|
||||
- **`renovate.yaml`** (Trigger: wöchentlich montags 04:00 UTC, `workflow_dispatch`): Führt Renovate (Container-Image, gepinnte Version) gegen die Gitea-Plattform aus und erstellt Pull Requests für veraltete Abhängigkeiten gemäß `renovate.json`.
|
||||
|
||||
### 5.3 Renovate-Konfiguration (`renovate.json`)
|
||||
- Basis-Branch für PRs: `dev`.
|
||||
- Gruppiert Updates nach `Gitea Actions`, `Cargo Dependencies` und `Docker-Images`.
|
||||
- Custom-Regex-Manager halten die in den Workflows gepinnten Tool-Versionen aktuell: `TRIVY_VERSION`, `OSV_SCANNER_VERSION`, `TRUFFLEHOG_VERSION`, `CARGO_BINSTALL_VERSION`, `CARGO_DEB_VERSION`, `CARGO_GENERATE_RPM_VERSION`.
|
||||
- Zeitplan: vor 6 Uhr montags (Europe/Berlin).
|
||||
|
||||
### 5.4 Secrets
|
||||
- `PACKAGE_TOKEN` (Fallback-Kette: `RELEASE_TOKEN`, `PUBLISH_TOKEN`, `API_TOKEN`, `PAT_TOKEN`, `CUSTOM_TOKEN`, `GITEA_TOKEN`, `GITHUB_TOKEN`) — API-Zugriff auf Gitea Packages, Releases und die Container Registry.
|
||||
- `SECURITY_TOKEN` — wird von `security-scan.yaml` und `trufflehog-scan.yaml` für das Erstellen/Kommentieren von Gitea-Issues verwendet.
|
||||
- `RENOVATE_TOKEN` — Zugriffstoken für den Renovate-Lauf (Gitea-Plattform-Endpoint & Cargo-Registry-Host-Rule).
|
||||
Generated
+3081
File diff suppressed because it is too large
Load Diff
+96
-14
@@ -1,25 +1,107 @@
|
||||
[package]
|
||||
name = "SmartMount"
|
||||
version = "0.2.0"
|
||||
name = "smart-mount"
|
||||
version = "2.0.0"
|
||||
edition = "2024"
|
||||
authors = ['DragonSlayer_14']
|
||||
readme = "README.md"
|
||||
license-file = "LICENSE"
|
||||
repository = "https://gitea.creative-dragonslayer.de/creative-dragonslayer/SmartMount"
|
||||
description = "SmartMount ist ein innovatives Tool zur intelligenten Verwaltung von Netzwerk-Dateisystemen. Es ermöglicht das automatische Einbinden von Netzwerk-Freigaben über das lokale Netzwerk und wechselt nahtlos zu einer Cloud-basierten Lösung, falls keine lokale Verbindung verfügbar ist. Durch diese hybride Architektur wird ein zuverlässiger Zugriff auf wichtige Daten sichergestellt - egal ob zu Hause oder unterwegs."
|
||||
license = "GPL-3.0-or-later"
|
||||
repository = "https://gitea.creative-dragonslayer.de/Linuxapps/SmartMount"
|
||||
description = "Bindet lokale/Cloud-Laufwerkspaare (WebDAV/SMB/NFS) dynamisch ein und schaltet automatisch zwischen LAN und Cloud um"
|
||||
|
||||
[dependencies]
|
||||
time = { version="0.3.41", features = ["formatting", "macros", "local-offset"] }
|
||||
serde = { version="1.0.219", features = ["derive"] }
|
||||
confy = "1.0.0"
|
||||
libc = "1.0.0-alpha.1"
|
||||
config-ctdra = { version = "1.0.6", registry = "gitea" }
|
||||
logger-ctdra = { version = "1.0.5", registry = "gitea" }
|
||||
sudo-ctdra = { version = "1.0.1", registry = "gitea" }
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
clap_complete = "4"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "fs", "time", "sync"] }
|
||||
# WORKAROUND für RUSTSEC-2026-0253 (Use-after-free in lru < 0.18.2), siehe mac2ip Cargo.toml:
|
||||
# das Default-Feature "fts" zieht tantivy -> lru "^0.16.3" (verwundbar) ein, obwohl
|
||||
# smart-mount keine Volltextsuche nutzt. "fts" bleibt deaktiviert, bis eine stabile
|
||||
# turso-Version tantivy>=eine lru>=0.18.2 zulassende Version pinnt.
|
||||
turso = { version = "0.7", default-features = false, features = ["mimalloc"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
aes-gcm = "0.11.1"
|
||||
dialoguer = "0.12.0"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
# Eigene, direkte Zufallsquelle für Nonce-/Schlüssel-Generierung, entkoppelt von aes-gcms
|
||||
# rand_core-Re-Export-Kette (siehe crypto/mod.rs, crypto/key.rs).
|
||||
getrandom = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
toml = "1.1.6"
|
||||
|
||||
[profile.release]
|
||||
debug = "none"
|
||||
|
||||
[package.metadata.deb]
|
||||
section = "utils"
|
||||
priority = "optional"
|
||||
# --- Paketierungs-Metadaten für Linux-Distributionen ---
|
||||
|
||||
provides = ["smartmount"]
|
||||
depends = ["nmap", "$auto"]
|
||||
[package.metadata.deb]
|
||||
name = "smart-mount"
|
||||
maintainer = "DragonSlayer_14"
|
||||
copyright = "2026 DragonSlayer_14"
|
||||
section = "net"
|
||||
priority = "optional"
|
||||
# mac2ip und nmap sind harte Laufzeitabhängigkeiten (MAC->IP-Auflösung); davfs2/cifs-utils/
|
||||
# nfs-common werden bewusst NICHT hart verlangt, da smart-mount pro konfiguriertem Laufwerk
|
||||
# nur das jeweils benötigte Mount-Backend zur Laufzeit prüft/lädt (siehe MountBackend::check_available).
|
||||
depends = "$auto, mac2ip, nmap"
|
||||
recommends = "davfs2, cifs-utils, nfs-common"
|
||||
extended-description = """\
|
||||
smart-mount bindet Paare aus einem lokalen (LAN, WebDAV/SMB/NFS) und einem Cloud-Laufwerk
|
||||
ein: ist das lokale Laufwerk erreichbar, wird es gemountet, sonst automatisch das
|
||||
Cloud-Laufwerk. Ein Watchdog prüft periodisch die Erreichbarkeit und schaltet bei Bedarf
|
||||
zwischen beiden um. Zugangsdaten werden verschlüsselt in einer lokalen Turso-Datenbank
|
||||
gespeichert; smart-mount richtet sich beim Installieren automatisch als System-systemd-Dienst
|
||||
ein und mountet konfigurierte Laufwerkspaare für die jeweils zugeordneten lokalen Nutzer.\
|
||||
"""
|
||||
maintainer-scripts = "packaging/deb"
|
||||
assets = [
|
||||
["target/release/smart-mount", "usr/bin/smart-mount", "755"],
|
||||
["README.md", "usr/share/doc/smart-mount/README.md", "644"],
|
||||
["LICENSE", "usr/share/doc/smart-mount/copyright", "644"],
|
||||
["packaging/systemd/smart-mount-mount.service", "usr/lib/systemd/system/smart-mount-mount.service", "644"],
|
||||
["packaging/systemd/smart-mount-watch.service", "usr/lib/systemd/system/smart-mount-watch.service", "644"],
|
||||
["packaging/systemd/smart-mount-watch.timer", "usr/lib/systemd/system/smart-mount-watch.timer", "644"],
|
||||
]
|
||||
|
||||
[package.metadata.generate-rpm]
|
||||
assets = [
|
||||
{ source = "target/release/smart-mount", dest = "/usr/bin/smart-mount", mode = "755" },
|
||||
{ source = "README.md", dest = "/usr/share/doc/smart-mount/README.md", mode = "644", doc = true },
|
||||
{ source = "LICENSE", dest = "/usr/share/licenses/smart-mount/LICENSE", mode = "644", license = true },
|
||||
{ source = "packaging/systemd/smart-mount-mount.service", dest = "/usr/lib/systemd/system/smart-mount-mount.service", mode = "644" },
|
||||
{ source = "packaging/systemd/smart-mount-watch.service", dest = "/usr/lib/systemd/system/smart-mount-watch.service", mode = "644" },
|
||||
{ source = "packaging/systemd/smart-mount-watch.timer", dest = "/usr/lib/systemd/system/smart-mount-watch.timer", mode = "644" },
|
||||
]
|
||||
requires = { "mac2ip" = "*", "nmap" = "*" }
|
||||
suggests = { "davfs2" = "*", "cifs-utils" = "*", "nfs-utils" = "*" }
|
||||
# postinst/postrm-Äquivalente: richten den paketierten systemd-Dienst automatisch ein/entfernen
|
||||
# (Gegenstück zu packaging/deb/postinst+postrm). $1 in %postun: Anzahl verbleibender Versionen
|
||||
# nach diesem Schritt - 0 nur bei vollständiger Deinstallation, nicht bei einem Upgrade.
|
||||
post_install_script = """
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl daemon-reload || true
|
||||
systemctl enable --now smart-mount-mount.service smart-mount-watch.timer || true
|
||||
fi
|
||||
"""
|
||||
post_uninstall_script = """
|
||||
if [ "$1" = "0" ] && command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl disable --now smart-mount-mount.service smart-mount-watch.timer smart-mount-watch.service || true
|
||||
systemctl daemon-reload || true
|
||||
fi
|
||||
"""
|
||||
|
||||
[package.metadata.arch]
|
||||
pkgrel = "1" # Wird in CI durch get-build-number.py dynamisch überschrieben
|
||||
arch = "x86_64"
|
||||
depends = ["gcc-libs", "glibc", "mac2ip", "nmap"]
|
||||
optdepends = [
|
||||
"davfs2: WebDAV-Laufwerke einbinden",
|
||||
"cifs-utils: SMB/CIFS-Laufwerke einbinden",
|
||||
"nfs-utils: NFS-Laufwerke einbinden",
|
||||
]
|
||||
|
||||
@@ -209,7 +209,7 @@ If you develop a new program, and you want it to be of the greatest possible use
|
||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
||||
|
||||
SmartMount
|
||||
Copyright (C) 2025 DragonSlayer_14
|
||||
Copyright (C) 2026 creative-dragonslayer.de
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
@@ -221,7 +221,7 @@ Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
||||
|
||||
SmartMount Copyright (C) 2025 DragonSlayer_14
|
||||
SmartMount Copyright (C) 2026 creative-dragonslayer.de
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
||||
|
||||
|
||||
@@ -1,14 +1,322 @@
|
||||
# SmartMount
|
||||
|
||||
SmartMount ist ein innovatives Tool zur intelligenten Verwaltung von Netzwerk-Dateisystemen. Es ermöglicht das
|
||||
automatische Einbinden von Netzwerk-Freigaben über das lokale Netzwerk und wechselt nahtlos zu einer Cloud-basierten
|
||||
Lösung, falls keine lokale Verbindung verfügbar ist. Durch diese hybride Architektur wird ein zuverlässiger Zugriff auf
|
||||
wichtige Daten sichergestellt - egal ob zu Hause oder unterwegs.
|
||||
SmartMount bindet Paare aus einem **lokalen** (LAN, WebDAV/SMB/NFS) und einem **Cloud**-Laufwerk
|
||||
ein: ist das lokale Laufwerk erreichbar, wird es gemountet - sonst automatisch das
|
||||
Cloud-Laufwerk. Ein Watchdog prüft periodisch die Erreichbarkeit und schaltet bei Bedarf
|
||||
zwischen beiden um. Zugangsdaten werden verschlüsselt in einer lokalen [Turso](https://github.com/tursodatabase/turso)-Datenbank
|
||||
gespeichert, niemals im Klartext in der Konfigurationsdatei. Das Command heißt `smart-mount`.
|
||||
|
||||
### Build:
|
||||
Die Synchronisation der Inhalte zwischen lokalem und Cloud-Laufwerk ist **nicht** Teil dieses
|
||||
Programms - SmartMount setzt voraus, dass beide Seiten bereits inhaltlich synchron gehalten
|
||||
werden (z. B. über ein separates Sync-Tool).
|
||||
|
||||
Für Debian muss `cargo-deb` installiert sein, dann kann man das Paket mit diesem Paket builden:
|
||||
---
|
||||
|
||||
```shell
|
||||
cargo deb --separate-debug-symbols --compress-debug-symbols
|
||||
## Voraussetzungen
|
||||
|
||||
- **[`mac2ip`](https://gitea.creative-dragonslayer.de/Linuxapps/Mac2Ip)** und **`nmap`**: hart
|
||||
benötigt, sofern lokale Laufwerke per MAC-Adresse adressiert werden (Auflösung MAC → IP).
|
||||
- **`timeout`** (GNU coreutils): auf praktisch jedem Linux-System bereits vorhanden (Basis-
|
||||
Systempaket). Wird verwendet, um `mount`/`umount`-Aufrufe zeitlich zu begrenzen - siehe
|
||||
"Verhalten bei nicht mehr erreichbarem Server" unten.
|
||||
- **Pro genutztem Mount-Typ** (nur was tatsächlich konfiguriert ist, wird zur Laufzeit
|
||||
geprüft - siehe `MountBackend::check_available`):
|
||||
- `davfs2` für WebDAV-Laufwerke
|
||||
- `cifs-utils` für SMB/CIFS-Laufwerke
|
||||
- `nfs-common` (Debian/Ubuntu) bzw. `nfs-utils` (Fedora/Arch) für NFS-Laufwerke
|
||||
- `systemd`: smart-mount richtet sich beim Installieren des Pakets automatisch als
|
||||
System-Dienst ein (siehe "Automatischer Start beim Systemstart" unten). Ohne systemd bleibt
|
||||
`smart-mount service crontab` als manueller Fallback.
|
||||
|
||||
---
|
||||
|
||||
## CLI-Nutzung
|
||||
|
||||
smart-mount läuft ausschließlich als root/System-Dienst - es gibt nur eine einzige,
|
||||
systemweite Konfiguration. `drive add`/`edit`/`remove` sowie `mount`/`unmount`/`watch`
|
||||
verlangen daher explizit Root-Rechte (`sudo`), auch für ein Laufwerkspaar, das nur für einen
|
||||
bestimmten Nutzer gedacht ist (siehe `owner_user` unten). Rein lesende Befehle (`drive list`,
|
||||
`status`, `doctor`) brauchen kein `sudo`.
|
||||
|
||||
```bash
|
||||
# Neues Laufwerkspaar interaktiv anlegen (fragt Name, Mount-Typ, Adresse, Freigabe und ggf.
|
||||
# Zugangsdaten ab)
|
||||
sudo smart-mount drive add
|
||||
|
||||
# Nicht-interaktiv per Flags (für Skripte/Automatisierung) - fehlende Pflichtfelder sind
|
||||
# dann ein Fehler statt eines (ohne Terminal ohnehin unmöglichen) Prompts. Jedes
|
||||
# *-password-stdin-Flag liest genau eine Zeile von stdin (bei mehreren im selben Aufruf
|
||||
# entsprechend mehrere Zeilen, eine pro Flag in Reihenfolge):
|
||||
sudo smart-mount drive add --non-interactive \
|
||||
--name NAS \
|
||||
--local-kind smb --local-ip 192.168.1.50 --local-share share --local-username nasuser --local-password-stdin \
|
||||
--cloud-kind webdav --cloud-host https://cloud.example.com/dav --cloud-share / --cloud-username clouduser --cloud-password cloud-pw-direkt \
|
||||
<<< "lokales-passwort"
|
||||
# (--local-password/--cloud-password gehen auch direkt als Argument, sind aber im
|
||||
# Prozess-Listing/Shell-Verlauf sichtbar - für Skripte lieber *-stdin verwenden)
|
||||
|
||||
# Bestehendes Laufwerkspaar bearbeiten - nur angegebene Felder ändern sich, alles andere
|
||||
# (inkl. gespeichertem Passwort) bleibt unangetastet. Ohne Flags: interaktiver Durchlauf,
|
||||
# vorbelegt mit den aktuellen Werten (inkl. Rückfrage, ob das Passwort geändert werden soll).
|
||||
sudo smart-mount drive edit <id> --name "Neuer Name" --local-ip 192.168.1.99
|
||||
sudo smart-mount drive edit <id> --non-interactive --local-password-stdin <<< "neues-passwort"
|
||||
|
||||
# Konfigurierte Laufwerkspaare auflisten / entfernen (--json für Skripte)
|
||||
smart-mount drive list
|
||||
smart-mount drive list --json
|
||||
sudo smart-mount drive remove <id>
|
||||
|
||||
# Ein einzelnes Paar oder alle einbinden/aushängen
|
||||
sudo smart-mount mount --name <id>
|
||||
sudo smart-mount mount --all
|
||||
sudo smart-mount unmount --all
|
||||
|
||||
# Aktuellen Status (Mount-Zustand + Erreichbarkeit beider Seiten) anzeigen (--json für Skripte)
|
||||
smart-mount status
|
||||
smart-mount status --json
|
||||
|
||||
# Ein Reconcile-Durchlauf (lokal/Cloud-Umschaltung) - für systemd-Timer/Cron gedacht
|
||||
sudo smart-mount watch
|
||||
|
||||
# Cron-Fallback für Systeme ohne (genutzten) systemd - siehe "Automatischer Start beim
|
||||
# Systemstart" unten
|
||||
sudo smart-mount service crontab
|
||||
sudo smart-mount service crontab --remove
|
||||
|
||||
# Voraussetzungen prüfen (Binaries, Scheduler) - deckt gebündelt ab, was man sonst erst
|
||||
# einzeln beim Mount-Fehlschlag entdecken würde
|
||||
smart-mount doctor
|
||||
smart-mount doctor --json
|
||||
|
||||
# Shell-Completion-Skript ausgeben (bash/zsh/fish/elvish/powershell)
|
||||
smart-mount completions bash > /etc/bash_completion.d/smart-mount
|
||||
smart-mount completions zsh > "${fpath[1]}/_smart-mount"
|
||||
```
|
||||
|
||||
Die Konfiguration liegt immer unter `/etc/smart-mount/config.toml`, unabhängig davon, ob
|
||||
`smart-mount` selbst mit oder ohne Root-Rechte aufgerufen wird (siehe oben). Die verschlüsselte
|
||||
Zugangsdaten-Datenbank (`smart-mount.db`) liegt im selben Verzeichnis.
|
||||
|
||||
### Wo die Laufwerke eingebunden werden
|
||||
|
||||
Jedes Laufwerkspaar bekommt sein **eigenes** Unterverzeichnis unter `settings.mount_base_dir`,
|
||||
standardmäßig `<mount_base_dir>/<Name des Paares>` (z. B. `/run/media/NAS`) - mehrere Paare
|
||||
stören sich also nie gegenseitig, und der Ordnername ist beim Durchsuchen sofort erkennbar
|
||||
(statt einer kryptischen ID). Standardwert für `mount_base_dir`: `/run/media` - auf den
|
||||
meisten Systemen bereits die übliche Konvention für eingebundene Wechseldatenträger/
|
||||
Netzlaufwerke (z. B. udisks2/GNOME) und liegt auf `tmpfs`, muss also nie persistieren. Der
|
||||
Name wird dabei zu einem sicheren Verzeichnisnamen bereinigt (nur alphanumerische Zeichen
|
||||
sowie `-`/`_`, alles andere wird zu `-`); kollidiert das Ergebnis mit einem bereits
|
||||
bestehenden Paar (Namen müssen nicht eindeutig sein), wird `-2`, `-3`, ... angehängt.
|
||||
|
||||
**Der Mountpoint lässt sich beim Anlegen frei überschreiben** - per `--mount-point <pfad>`
|
||||
(nicht-interaktiv) oder im interaktiven Prompt, komplett unabhängig von `mount_base_dir`:
|
||||
|
||||
```bash
|
||||
sudo smart-mount drive add --mount-point /mnt/nas --non-interactive ...
|
||||
```
|
||||
|
||||
Bei `drive edit` bleibt der Mountpoint standardmäßig unverändert (kein Prompt dafür) - sonst
|
||||
würde ein aktiver Mount verwaisen. Nur eine explizite `--mount-point`-Angabe ändert ihn (mit
|
||||
einer Warnung, falls das Paar gerade aktiv gemountet ist - dann erst `unmount`, editieren,
|
||||
dann erneut `mount`).
|
||||
|
||||
Der Mountpoint selbst (und alle nötigen Elternverzeichnisse) werden bei jedem `mount`/
|
||||
`watch`-Lauf automatisch angelegt, falls sie fehlen - der gemeinsame Elternordner
|
||||
(`mount_base_dir`, bzw. bei einem komplett eigenen `--mount-point` dessen Elternordner) fest
|
||||
mit `0755` (durchquerbar für jeden lokalen Nutzer, unabhängig vom Umask des root-Prozesses),
|
||||
das jeweilige Backing-Verzeichnis (siehe unten) bei gesetztem `owner_user` zusätzlich fest
|
||||
`chown`t und `0700` (nur der Owner). Ist für ein Paar `owner_user` gesetzt, bekommt genau dieser
|
||||
Nutzer bei CIFS/WebDAV zusätzlich über die Mount-Optionen (`uid=`/`gid=`, siehe
|
||||
"Architekturentscheidungen" unten) vollen Zugriff auf den *Inhalt* - die Trennung passiert also
|
||||
über Zugriffsrechte, nicht über getrennte Mountpoint-Namensräume pro Nutzer.
|
||||
|
||||
Der Standard für `mount_base_dir` lässt sich in `config.toml` unter
|
||||
`[settings] mount_base_dir = "..."` jederzeit auf einen beliebigen anderen Pfad ändern.
|
||||
|
||||
---
|
||||
|
||||
## Automatischer Start beim Systemstart
|
||||
|
||||
Das .deb/.rpm/.pkg.tar.zst-Paket richtet smart-mount beim Installieren **automatisch** als
|
||||
System-systemd-Dienst ein - kein manueller Schritt nötig. Die dafür paketierten Unit-Dateien
|
||||
(`packaging/systemd/` im Quellbaum, installiert nach `/usr/lib/systemd/system/`) bestehen aus:
|
||||
|
||||
- `smart-mount-mount.service`: ein `oneshot`-Service, der beim Boot `smart-mount mount --all`
|
||||
aufruft (nach `network-online.target`).
|
||||
- `smart-mount-watch.service` + `smart-mount-watch.timer`: ein Timer, der periodisch
|
||||
`smart-mount watch` aufruft (Standardintervall: 120s).
|
||||
|
||||
Die postinst/postrm-Skripte des Pakets (bzw. das `.INSTALL`-Skriptlet bei Arch) aktivieren und
|
||||
starten beide Units beim Installieren/Upgraden (`systemctl enable --now ...`) und deaktivieren
|
||||
sie wieder beim Deinstallieren (`systemctl disable --now ...`) - siehe `packaging/deb/postinst`
|
||||
+ `packaging/deb/postrm` bzw. `[package.metadata.generate-rpm]`/`scripts/package-arch.py` in
|
||||
`Cargo.toml`. Welche Laufwerkspaare dabei gemountet werden (und für welchen Nutzer, siehe
|
||||
`owner_user` unten) steuert ausschließlich `/etc/smart-mount/config.toml` - nicht die
|
||||
Unit-Dateien selbst.
|
||||
|
||||
**Anderes Watch-Intervall als der Standard (120s):** `settings.watch_interval_secs` in
|
||||
`config.toml` steuert nur den Cron-Fallback (s. u.) - der paketierte Timer hat ein fest
|
||||
eingebautes Intervall. Zum Anpassen:
|
||||
|
||||
```bash
|
||||
sudo systemctl edit smart-mount-watch.timer
|
||||
# [Timer]
|
||||
# OnUnitActiveSec=60s
|
||||
```
|
||||
|
||||
### Cron-Fallback (Systeme ohne systemd)
|
||||
|
||||
```bash
|
||||
sudo smart-mount service crontab # einrichten
|
||||
sudo smart-mount service crontab --remove # wieder entfernen
|
||||
```
|
||||
|
||||
Erfordert Root (wie `mount`/`watch` - Mounten läuft immer als root, siehe oben) und schreibt
|
||||
`/etc/cron.d/smart-mount` - das Intervall folgt `settings.watch_interval_secs`. Ist
|
||||
`/etc/cron.d` nicht vorhanden, werden stattdessen die beiden äquivalenten Zeilen zum manuellen
|
||||
Eintragen ausgegeben:
|
||||
|
||||
```cron
|
||||
@reboot smart-mount mount --all
|
||||
*/2 * * * * smart-mount watch
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architekturentscheidungen
|
||||
|
||||
- **Verhalten bei nicht mehr erreichbarem Server (der ganze Sinn dieses Tools)**: Netzwerk-
|
||||
Dateisysteme können unter Linux "einfrieren", wenn der Server verschwindet - `umount`
|
||||
oder Schreibzugriffe hängen dann scheinbar unbegrenzt. Das wird gezielt adressiert:
|
||||
- **NFS**: Standardmäßig setzt `mount.nfs` `hard` (unbegrenzte Wiederholungsversuche bei
|
||||
Zeitüberschreitung) - genau das Einfrierverhalten. SmartMount setzt automatisch `soft`,
|
||||
sofern nicht bereits `hard`/`soft`/`softerr` explizit in `extra_options` gesetzt ist
|
||||
(`man 5 nfs`, Abschnitt "soft / softerr / hard" nennt einen dauerhaft nicht erreichbaren
|
||||
Server explizit als Zielszenario für `soft`).
|
||||
- **CIFS**: `mount.cifs` verwendet laut eigener Dokumentation bereits standardmäßig `soft` -
|
||||
kein Eingriff nötig.
|
||||
- **WebDAV (davfs2)**: `umount` ist laut davfs2-eigener FAQ *absichtlich* so lange
|
||||
blockierend, bis alle zwischengespeicherten Daten geschrieben sind - bei einem dauerhaft
|
||||
unerreichbaren Server kehrt das nie zurück. Dafür gibt es keine passende Mount-Option.
|
||||
`umount -l` (lazy) würde zwar sofort zurückkehren, macht laut `man umount` aber
|
||||
**künftige Remounts derselben Freigabe bis zum nächsten Neustart unmöglich** - für ein
|
||||
Tool, dessen ganzer Zweck automatisches Zurückschalten ist, wäre das schlimmer als das
|
||||
ursprüngliche Problem.
|
||||
- **Der eigentliche Fix**: jeder `mount`/`umount`-Aufruf läuft über das coreutils-Tool
|
||||
`timeout` (30s). Bei `umount` wird ein Abbruch wegen Zeitüberschreitung toleriert (nur
|
||||
geloggt, `Ok`) statt als Fehler behandelt - der nächste `watch`-Durchlauf versucht es
|
||||
erneut (idempotent). Dadurch kann `smart-mount watch`/`unmount` selbst dann nicht mehr
|
||||
unbegrenzt hängen bleiben, wenn eine einzelne Seite dauerhaft nicht antwortet - und weil
|
||||
beim Umschalten zuerst die neue Seite gemountet und der Symlink umgebogen wird, bevor die
|
||||
alte Seite (mit eben diesem tolerierten Timeout) ausgehängt wird, blockiert ein
|
||||
hängender Alt-Mount den sichtbaren Wechsel ohnehin nicht.
|
||||
- **Privilegienmodell**: SmartMount läuft ausschließlich als root/System-Dienst - es gibt
|
||||
keinen unprivilegierten Mount-Weg mehr (früher: `/etc/fstab`-Einträge mit `user,noauto`, über
|
||||
die ein einzelner Nutzer unprivilegiert selbst mounten konnte). Root mountet direkt
|
||||
(`mount -t <typ> <quelle> <ziel> -o <optionen>`) für jedes konfigurierte Paar, unabhängig
|
||||
davon, ob/für welchen Nutzer `owner_user` gesetzt ist - siehe `owner_user` unten für die
|
||||
Zugriffssteuerung. Das vereinfacht das Rechtemodell erheblich und schließt nebenbei die
|
||||
frühere strukturelle CIFS-Lücke (jeder unprivilegiert mount-berechtigte Nutzer konnte zuvor
|
||||
jedes CIFS-Nutzer-Kontext-Paar mounten, nicht nur sein eigenes) - Zugangsdaten sind jetzt nur
|
||||
noch root zugänglich.
|
||||
- **Voller Zugriff für einen bestimmten Nutzer (`owner_user`)**: Bei CIFS/WebDAV (keine
|
||||
nativen Unix-Rechte) setzt SmartMount automatisch `uid=`/`gid=`/`file_mode=0700`/
|
||||
`dir_mode=0700`, sobald ein Paar ein `owner_user` hat - optional, ohne `owner_user` gehört
|
||||
der Mount root. `smart-mount drive add` setzt `owner_user` standardmäßig auf den Nutzer, der
|
||||
`sudo` aufgerufen hat (`$SUDO_USER`) - wer ein Paar einrichtet, bekommt also automatisch
|
||||
vollen Zugriff darauf, sofern nicht per `--owner-user` (nicht-interaktiv) oder im
|
||||
interaktiven Prompt bewusst ein anderer Nutzer angegeben bzw. abgelehnt wird. Bei NFS gibt es
|
||||
keine clientseitige `uid=`/`gid=`-Option, Zugriff bestimmt dort ausschließlich der Server über
|
||||
die tatsächlichen Datei-Eigentümer/-Rechte des Exports - `owner_user` wirkt sich hier nur auf
|
||||
das lokale Backing-Verzeichnis selbst aus (`chown`+`0700`, s. o.), nicht auf das, was der
|
||||
Server tatsächlich exportiert.
|
||||
- **davfs2-Konfiguration**: SmartMount setzt in `davfs2.conf` automatisch `gui_optimize 1`
|
||||
(bündelt PROPFIND-Anfragen, wichtig für grafische Dateimanager) sowie `buf_size 16384`
|
||||
(deutlich über dem Standard von 16 KiB) - Letzteres behebt ein bekanntes Praxisproblem, bei
|
||||
dem `ls` in Verzeichnissen mit vielen Dateien einen leeren/unvollständigen Inhalt zeigt,
|
||||
obwohl einzelne Dateien direkt geöffnet werden können (der FUSE-readdir-Puffer wird bei zu
|
||||
kleinem `buf_size` stillschweigend abgeschnitten).
|
||||
- **Zugangsdaten-Verschlüsselung**: Turso hat aktuell keine produktionsreife eingebaute
|
||||
Verschlüsselung, daher verschlüsselt SmartMount Passwörter selbst (AES-256-GCM) vor der
|
||||
Ablage. Der Master-Schlüssel liegt in einer Schlüsseldatei (`chmod 600`, neben der
|
||||
Konfiguration, also `/etc/smart-mount/master.key`) - kein OS-Keyring, da SmartMount
|
||||
ausschließlich als root/System-Dienst läuft, für den es kein Nutzer-Keyring gibt.
|
||||
- **`mac2ip`-Integration**: SmartMount ruft `mac2ip --json --auto-trust-networks <mac>` auf.
|
||||
`--auto-trust-networks` lässt mac2ip die eigene "nmap-Scan in diesem Netzwerk erlauben?"-
|
||||
Rückfrage automatisch bejahen und dauerhaft in seiner eigenen Cache-Datenbank merken - das
|
||||
erspart SmartMount, das Konfigurationsschema eines fremden Tools zu kennen oder dort direkt
|
||||
hineinzuschreiben.
|
||||
|
||||
---
|
||||
|
||||
## Struktur des Repositories
|
||||
|
||||
```text
|
||||
├── .cargo/
|
||||
│ └── config.toml # Cargo-Konfiguration (Linker für Cross-Compiling, Registry)
|
||||
├── .gitea/
|
||||
│ └── workflows/ # CI/CD-Pipelines (Build, Tests, Security-Scans, Releases)
|
||||
├── packaging/
|
||||
│ ├── systemd/ # Statische systemd-Unit-Dateien (paketiert nach
|
||||
│ │ # /usr/lib/systemd/system/)
|
||||
│ └── deb/ # postinst/postrm für das .deb-Paket
|
||||
├── scripts/
|
||||
│ ├── get-build-number.py # Dynamische Ermittlung der nächsten Paket-Revisionsnummer
|
||||
│ ├── package-arch.py # Erstellung von Arch Linux .pkg.tar.zst Paketen
|
||||
│ └── report-security-issue.py # Security-Scan-Ergebnisse als Gitea-Issue melden
|
||||
├── src/
|
||||
│ ├── main.rs # Dünner Einstiegspunkt (CLI-Parsing, Dispatch)
|
||||
│ ├── lib.rs # Bibliotheks-Wurzel
|
||||
│ ├── cli/ # `clap`-Subcommands
|
||||
│ ├── config/ # Konfigurationsschema + CRUD (config-ctdra)
|
||||
│ ├── crypto/ # Verschlüsselung + Master-Key-Auflösung
|
||||
│ ├── db/ # Verschlüsselte Zugangsdaten (Turso)
|
||||
│ ├── mount/ # WebDAV/SMB/NFS-Backends, dynamischer Dispatch
|
||||
│ ├── network/ # Erreichbarkeit, mac2ip-Integration
|
||||
│ ├── reconcile/ # Watchdog-Entscheidungslogik (lokal/Cloud-Umschaltung)
|
||||
│ └── systemd/ # Cron-Fallback (Systeme ohne systemd)
|
||||
├── tests/ # Cross-Modul-Integrationstests
|
||||
├── Cargo.toml # Cargo Manifest & Paketierungsmetadaten (deb, rpm, arch)
|
||||
├── LICENSE # Lizenzdatei (GPL-3.0-or-later)
|
||||
├── AGENTS.md # Richtlinien und Leitfaden für KI-Coding-Agenten
|
||||
└── README.md # Diese Datei
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lokale Entwicklung
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
cargo test
|
||||
cargo build --release
|
||||
|
||||
# Debian-Paket bauen (.deb)
|
||||
cargo deb
|
||||
|
||||
# RPM-Paket bauen (.rpm)
|
||||
cargo generate-rpm
|
||||
|
||||
# Arch Linux-Paket bauen (.pkg.tar.zst)
|
||||
python3 scripts/package-arch.py --arch x86_64 --pkgrel 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI/CD Workflow-Übersicht
|
||||
|
||||
| Branch | Workflow | Paket-Kanal | Release-Typ |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `main` | `.gitea/workflows/main.yaml` | `stable` | Offizielles Release (`v<VERSION>`) |
|
||||
| `testing` | `.gitea/workflows/testing.yaml` | `testing` | Pre-Release (`v<VERSION>-preview`) |
|
||||
|
||||
Zusätzlich: automatisierte Security-Scans (Trivy, OSV-Scanner, TruffleHog), Renovate für
|
||||
Abhängigkeits-Updates, sowie automatisiertes `cargo fmt`/`cargo clippy --fix` auf dem
|
||||
`dev`-Branch.
|
||||
|
||||
---
|
||||
|
||||
## Lizenz
|
||||
|
||||
[GPL-3.0-or-later](LICENSE).
|
||||
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# Richtet smart-mount beim Installieren/Upgraden des Pakets automatisch als
|
||||
# System-systemd-Dienst ein (Gegenstueck: postrm). Laeuft nur beim eigentlichen
|
||||
# "configure"-Schritt (siehe Debian Policy Manual, Abschnitt 6.5), nicht bei
|
||||
# "abort-upgrade"/"abort-remove" etc.
|
||||
set -e
|
||||
|
||||
if [ "$1" = "configure" ] && command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl daemon-reload || true
|
||||
systemctl enable --now smart-mount-mount.service smart-mount-watch.timer || true
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# Entfernt den beim Installieren eingerichteten systemd-Dienst wieder (Gegenstueck:
|
||||
# postinst). Nur bei tatsaechlicher Entfernung ("remove"/"purge"), nicht bei einem
|
||||
# Upgrade (dort ersetzt dpkg die Unit-Dateien einfach durch die neue Version, ohne den
|
||||
# laufenden Dienst zwischenzeitlich zu deaktivieren).
|
||||
set -e
|
||||
|
||||
if { [ "$1" = "remove" ] || [ "$1" = "purge" ]; } && command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl disable --now smart-mount-mount.service smart-mount-watch.timer smart-mount-watch.service || true
|
||||
systemctl daemon-reload || true
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=smart-mount: mount configured drive pairs at boot
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/smart-mount mount --all
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,6 @@
|
||||
[Unit]
|
||||
Description=smart-mount: check reachability and switch local/cloud if needed
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/smart-mount watch
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=smart-mount: periodic reconciling
|
||||
|
||||
[Timer]
|
||||
OnBootSec=1min
|
||||
OnUnitActiveSec=120s
|
||||
Persistent=true
|
||||
Unit=smart-mount-watch.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
#-------------------------------------------------------------------------------#
|
||||
# Qodana analysis is configured by qodana.yaml file #
|
||||
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
|
||||
#-------------------------------------------------------------------------------#
|
||||
|
||||
#################################################################################
|
||||
# WARNING: Do not store sensitive information in this file, #
|
||||
# as its contents will be included in the Qodana report. #
|
||||
#################################################################################
|
||||
version: "1.0"
|
||||
|
||||
#Specify inspection profile for code analysis
|
||||
profile:
|
||||
name: qodana.starter
|
||||
|
||||
#Enable inspections
|
||||
#include:
|
||||
# - name: <SomeEnabledInspectionId>
|
||||
|
||||
#Disable inspections
|
||||
#exclude:
|
||||
# - name: <SomeDisabledInspectionId>
|
||||
# paths:
|
||||
# - <path/where/not/run/inspection>
|
||||
|
||||
#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
|
||||
#bootstrap: sh ./prepare-qodana.sh
|
||||
|
||||
#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
|
||||
#plugins:
|
||||
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
|
||||
|
||||
# Quality gate. Will fail the CI/CD pipeline if any condition is not met
|
||||
# severityThresholds - configures maximum thresholds for different problem severities
|
||||
# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code
|
||||
# dependencyLicenses - fails the run on prohibited or unknown dependency licenses
|
||||
# Code Coverage is available in Ultimate and Ultimate Plus plans
|
||||
#failureConditions:
|
||||
# severityThresholds:
|
||||
# any: 15
|
||||
# critical: 5
|
||||
# testCoverageThresholds:
|
||||
# fresh: 70
|
||||
# total: 50
|
||||
# dependencyLicenses:
|
||||
# failOnProhibited: true
|
||||
# failOnUnknown: false
|
||||
|
||||
#Specify Qodana linter for analysis (Applied in CI/CD pipeline)
|
||||
linter: jetbrains/qodana-<linter>:2026.2
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"],
|
||||
"timezone": "Europe/Berlin",
|
||||
"schedule": ["before 6am on monday"],
|
||||
"baseBranchPatterns": [
|
||||
"dev"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchFileNames": [".gitea/workflows/**"],
|
||||
"groupName": "Gitea Actions",
|
||||
"separateMajorMinor": false,
|
||||
"separateMinorPatch": false
|
||||
},
|
||||
{
|
||||
"matchManagers": ["cargo"],
|
||||
"groupName": "Cargo Dependencies",
|
||||
"separateMajorMinor": false,
|
||||
"separateMinorPatch": false
|
||||
},
|
||||
{
|
||||
"matchManagers": ["dockerfile", "docker-compose"],
|
||||
"groupName": "Docker-Images",
|
||||
"separateMajorMinor": false,
|
||||
"separateMinorPatch": false
|
||||
}
|
||||
],
|
||||
"customManagers": [
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": [
|
||||
"/^\\.gitea/workflows/.+\\.ya?ml$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"TRIVY_VERSION:\\s*\"(?<currentValue>[^\"]+)\""
|
||||
],
|
||||
"depNameTemplate": "aquasecurity/trivy",
|
||||
"datasourceTemplate": "github-releases",
|
||||
"extractVersionTemplate": "^v(?<version>.*)$"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": [
|
||||
"/^\\.gitea/workflows/.+\\.ya?ml$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"OSV_SCANNER_VERSION:\\s*\"(?<currentValue>[^\"]+)\""
|
||||
],
|
||||
"depNameTemplate": "google/osv-scanner",
|
||||
"datasourceTemplate": "github-releases",
|
||||
"extractVersionTemplate": "^v(?<version>.*)$"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": [
|
||||
"/^\\.gitea/workflows/.+\\.ya?ml$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"TRUFFLEHOG_VERSION:\\s*\"(?<currentValue>[^\"]+)\""
|
||||
],
|
||||
"depNameTemplate": "trufflesecurity/trufflehog",
|
||||
"datasourceTemplate": "github-releases",
|
||||
"extractVersionTemplate": "^v(?<version>.*)$"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": [
|
||||
"/^\\.gitea/workflows/.+\\.ya?ml$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"CARGO_BINSTALL_VERSION:\\s*\"(?<currentValue>[^\"]+)\""
|
||||
],
|
||||
"depNameTemplate": "cargo-bins/cargo-binstall",
|
||||
"datasourceTemplate": "github-releases",
|
||||
"extractVersionTemplate": "^v(?<version>.*)$"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": [
|
||||
"/^\\.gitea/workflows/.+\\.ya?ml$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"CARGO_DEB_VERSION:\\s*\"(?<currentValue>[^\"]+)\""
|
||||
],
|
||||
"depNameTemplate": "cargo-deb",
|
||||
"datasourceTemplate": "crate"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": [
|
||||
"/^\\.gitea/workflows/.+\\.ya?ml$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"CARGO_GENERATE_RPM_VERSION:\\s*\"(?<currentValue>[^\"]+)\""
|
||||
],
|
||||
"depNameTemplate": "cargo-generate-rpm",
|
||||
"datasourceTemplate": "crate"
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+178
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Ermittelt automatisch die nächste Build-Nummer (Revision / Release / pkgrel)
|
||||
für eine gegebene Paket-Version (z. B. 1.0.2 -> wenn 1.0.2-1 existiert, wird 2 zurückgegeben).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
# Paket-Typen, unter denen dasselbe Release veroeffentlicht wird und die sich
|
||||
# daher eine gemeinsame Build-Nummer teilen muessen.
|
||||
PACKAGE_TYPES = ("debian", "rpm", "arch")
|
||||
|
||||
|
||||
def get_cargo_pkg_info():
|
||||
try:
|
||||
metadata = json.loads(subprocess.check_output(["cargo", "metadata", "--format-version", "1", "--no-deps"]))
|
||||
pkg = metadata["packages"][0]
|
||||
name = pkg.get("name", "")
|
||||
version = pkg.get("version", "0.0.0")
|
||||
repository = pkg.get("repository", "")
|
||||
return name, version, repository
|
||||
except Exception:
|
||||
# Fallback auf einfaches Parsen der Cargo.toml
|
||||
name = ""
|
||||
version = "0.0.0"
|
||||
repository = ""
|
||||
if os.path.exists("Cargo.toml"):
|
||||
with open("Cargo.toml", "r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line.startswith("name ="):
|
||||
name = line.split("=", 1)[1].strip().strip('"\'')
|
||||
elif line.startswith("version ="):
|
||||
version = line.split("=", 1)[1].strip().strip('"\'')
|
||||
elif line.startswith("repository ="):
|
||||
repository = line.split("=", 1)[1].strip().strip('"\'')
|
||||
return name, version, repository
|
||||
|
||||
|
||||
def parse_repo_info(repo_url):
|
||||
"""Extrahiert Server-URL, Owner und Repo-Name aus einer Repository-URL."""
|
||||
if not repo_url:
|
||||
return None, None, None
|
||||
parsed = urllib.parse.urlparse(repo_url)
|
||||
server_url = f"{parsed.scheme}://{parsed.netloc}"
|
||||
path_parts = [p for p in parsed.path.strip("/").split("/") if p]
|
||||
if len(path_parts) >= 2:
|
||||
owner = path_parts[0]
|
||||
repo_name = path_parts[1]
|
||||
if repo_name.endswith(".git"):
|
||||
repo_name = repo_name[:-4]
|
||||
return server_url, f"{owner}/{repo_name}", owner
|
||||
return server_url, None, None
|
||||
|
||||
|
||||
def query_existing_build_numbers(name, version, gitea_url, repo, owner, token=None):
|
||||
build_nums = set()
|
||||
pattern = re.compile(rf'(?:{re.escape(name)}[_-]|^v?){re.escape(version)}-(\d+)')
|
||||
|
||||
headers = {"User-Agent": f"{name}-build-resolver" if name else "rust-build-resolver"}
|
||||
if token:
|
||||
headers["Authorization"] = f"token {token}"
|
||||
|
||||
# 1. Gitea Releases API
|
||||
if gitea_url and repo:
|
||||
try:
|
||||
url = f"{gitea_url.rstrip('/')}/api/v1/repos/{repo}/releases?limit=50"
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
releases = json.loads(resp.read().decode())
|
||||
for rel in releases:
|
||||
tag = rel.get("tag_name", "")
|
||||
m = pattern.search(tag)
|
||||
if m:
|
||||
build_nums.add(int(m.group(1)))
|
||||
for asset in rel.get("assets", []):
|
||||
asset_name = asset.get("name", "")
|
||||
m = pattern.search(asset_name)
|
||||
if m:
|
||||
build_nums.add(int(m.group(1)))
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"[Hinweis] Konnte Gitea Releases nicht abfragen: {e}\n")
|
||||
|
||||
# 2. Gitea Packages API (falls Token vorhanden): pro Paket-Typ gezielt nur die
|
||||
# neueste Version abfragen (ein Request, keine Pagination noetig). Das haelt
|
||||
# die Abfrage schnell unabhaengig von der Historie und ist unbeeinflusst von
|
||||
# Docker-Tags/anderen Paket-Typen, die unter demselben Owner haengen.
|
||||
if gitea_url and owner and token and name:
|
||||
for pkg_type in PACKAGE_TYPES:
|
||||
try:
|
||||
url = (
|
||||
f"{gitea_url.rstrip('/')}/api/v1/packages/{owner}/"
|
||||
f"{pkg_type}/{urllib.parse.quote(name, safe='')}/-/latest"
|
||||
)
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
pkg = json.loads(resp.read().decode())
|
||||
pkg_ver = pkg.get("version", "")
|
||||
m = pattern.search(pkg_ver)
|
||||
if m:
|
||||
build_nums.add(int(m.group(1)))
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code != 404:
|
||||
sys.stderr.write(f"[Hinweis] Konnte neueste {pkg_type}-Paketversion nicht abfragen: {e}\n")
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"[Hinweis] Konnte neueste {pkg_type}-Paketversion nicht abfragen: {e}\n")
|
||||
|
||||
# 3. Lokales Target-Verzeichnis prüfen
|
||||
target_dirs = ["target/debian", "target/generate-rpm", "target/arch"]
|
||||
for t_dir in target_dirs:
|
||||
if os.path.isdir(t_dir):
|
||||
for file in os.listdir(t_dir):
|
||||
m = pattern.search(file)
|
||||
if m:
|
||||
build_nums.add(int(m.group(1)))
|
||||
|
||||
return build_nums
|
||||
|
||||
|
||||
def get_next_build_number(version=None, gitea_url=None, repo=None, owner=None, token=None, explicit_build_num=None):
|
||||
if explicit_build_num is not None:
|
||||
return int(explicit_build_num)
|
||||
|
||||
env_build_num = os.environ.get("BUILD_NUMBER") or os.environ.get("BUILD_NUM") or os.environ.get("PKGREL")
|
||||
if env_build_num:
|
||||
try:
|
||||
return int(env_build_num)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
pkg_name, pkg_version, pkg_repo = get_cargo_pkg_info()
|
||||
if not version:
|
||||
version = pkg_version
|
||||
|
||||
default_server, default_repo, default_owner = parse_repo_info(pkg_repo)
|
||||
|
||||
gitea_url = gitea_url or os.environ.get("GITEA_URL") or os.environ.get("GITHUB_SERVER_URL") or default_server
|
||||
repo = repo or os.environ.get("REPO") or os.environ.get("GITHUB_REPOSITORY") or default_repo
|
||||
owner = owner or os.environ.get("REPO_OWNER") or os.environ.get("GITHUB_REPOSITORY_OWNER") or default_owner
|
||||
token = token or os.environ.get("TOKEN") or os.environ.get("GITEA_TOKEN") or os.environ.get("PACKAGE_TOKEN") or os.environ.get("RELEASE_TOKEN")
|
||||
|
||||
existing_nums = query_existing_build_numbers(pkg_name, version, gitea_url, repo, owner, token)
|
||||
if existing_nums:
|
||||
return max(existing_nums) + 1
|
||||
return 1
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Ermittelt die nächste Build-Nummer für die Paketierung.")
|
||||
parser.add_argument("--version", help="Paket-Version (Standard: aus Cargo.toml)")
|
||||
parser.add_argument("--gitea-url", help="Gitea Basis-URL")
|
||||
parser.add_argument("--repo", help="Repository (z. B. Owner/Repo)")
|
||||
parser.add_argument("--owner", help="Repository Owner / Organisation")
|
||||
parser.add_argument("--token", help="API-Token")
|
||||
parser.add_argument("--build-number", type=int, help="Explizite Build-Nummer erzwingen")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
build_num = get_next_build_number(
|
||||
version=args.version,
|
||||
gitea_url=args.gitea_url,
|
||||
repo=args.repo,
|
||||
owner=args.owner,
|
||||
token=args.token,
|
||||
explicit_build_num=args.build_number,
|
||||
)
|
||||
print(build_num)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+208
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
TARGET_ARCH_MAP = {
|
||||
"x86_64-unknown-linux-gnu": "x86_64",
|
||||
"x86_64-unknown-linux-musl": "x86_64",
|
||||
"x86_64": "x86_64",
|
||||
"amd64": "x86_64",
|
||||
"aarch64-unknown-linux-gnu": "aarch64",
|
||||
"aarch64-unknown-linux-musl": "aarch64",
|
||||
"aarch64": "aarch64",
|
||||
"arm64": "aarch64",
|
||||
}
|
||||
|
||||
|
||||
def resolve_pkgrel(version=None, default="1"):
|
||||
# 1. Environment Variable
|
||||
env_pkgrel = os.environ.get("BUILD_NUMBER") or os.environ.get("BUILD_NUM") or os.environ.get("PKGREL")
|
||||
if env_pkgrel:
|
||||
return str(env_pkgrel)
|
||||
|
||||
# 2. get-build-number.py falls vorhanden
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
getter_path = os.path.join(script_dir, "get-build-number.py")
|
||||
if os.path.exists(getter_path):
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location("get_build_number", getter_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return str(module.get_next_build_number(version=version))
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"[Hinweis] Konnte get-build-number nicht ausführen: {e}\n")
|
||||
|
||||
return str(default)
|
||||
|
||||
|
||||
def collect_systemd_units(systemd_src_dir="packaging/systemd"):
|
||||
"""Findet die im Projekt paketierten systemd-Unit-Dateien (falls vorhanden) und
|
||||
unterscheidet dabei generisch - ohne einen Anwendungsnamen zu kennen - zwischen direkt
|
||||
aktivierbaren Units (die einen '[Install]'-Abschnitt haben, z. B. Timer oder ein beim Boot
|
||||
laufender oneshot-Service) und rein abhängigen Units (z. B. ein nur von einem Timer
|
||||
ausgelöster Service ohne eigenen '[Install]'-Abschnitt, siehe `man systemd.unit`)."""
|
||||
if not os.path.isdir(systemd_src_dir):
|
||||
return [], []
|
||||
|
||||
unit_files = sorted(
|
||||
f for f in os.listdir(systemd_src_dir) if f.endswith((".service", ".timer", ".socket"))
|
||||
)
|
||||
installable = []
|
||||
for f in unit_files:
|
||||
with open(os.path.join(systemd_src_dir, f)) as fh:
|
||||
if "[Install]" in fh.read():
|
||||
installable.append(f)
|
||||
return unit_files, installable
|
||||
|
||||
|
||||
def build_install_scriptlet(installable, all_units):
|
||||
"""Erzeugt den Inhalt einer Arch-'.INSTALL'-Datei (siehe `man PKGBUILD`, Abschnitt
|
||||
'install'), die den paketierten systemd-Dienst beim Installieren aktiviert/startet und beim
|
||||
Entfernen wieder deaktiviert/stoppt - rein generisch anhand der tatsächlich gefundenen
|
||||
Unit-Dateien, ohne Anwendungsnamen hart zu codieren."""
|
||||
installable_str = " ".join(installable)
|
||||
all_units_str = " ".join(all_units)
|
||||
return f"""post_install() {{
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
systemctl enable --now {installable_str} >/dev/null 2>&1 || true
|
||||
}}
|
||||
|
||||
post_upgrade() {{
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
}}
|
||||
|
||||
pre_remove() {{
|
||||
systemctl disable --now {all_units_str} >/dev/null 2>&1 || true
|
||||
}}
|
||||
|
||||
post_remove() {{
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def build_package(target_triple=None, target_arch=None, pkgrel=None):
|
||||
metadata = json.loads(subprocess.check_output(["cargo", "metadata", "--format-version", "1", "--no-deps"]))
|
||||
pkg = metadata["packages"][0]
|
||||
name = pkg["name"]
|
||||
version = pkg["version"]
|
||||
description = pkg.get("description", "")
|
||||
license_name = pkg.get("license", "")
|
||||
repository = pkg.get("repository", "")
|
||||
authors = pkg.get("authors", [])
|
||||
author = authors[0] if authors else "Unknown"
|
||||
|
||||
arch_meta = pkg.get("metadata", {}).get("arch", {})
|
||||
default_pkgrel = arch_meta.get("pkgrel", "1")
|
||||
if not pkgrel:
|
||||
pkgrel = resolve_pkgrel(version=version, default=default_pkgrel)
|
||||
default_arch = arch_meta.get("arch", "x86_64")
|
||||
|
||||
if target_arch:
|
||||
arch = TARGET_ARCH_MAP.get(target_arch, target_arch)
|
||||
elif target_triple:
|
||||
arch = TARGET_ARCH_MAP.get(target_triple, default_arch)
|
||||
else:
|
||||
arch = default_arch
|
||||
|
||||
candidate_paths = []
|
||||
if target_triple:
|
||||
candidate_paths.append(f"target/{target_triple}/release/{name}")
|
||||
candidate_paths.append(f"target/release/{name}")
|
||||
|
||||
bin_path = None
|
||||
for p in candidate_paths:
|
||||
if os.path.exists(p):
|
||||
bin_path = p
|
||||
break
|
||||
|
||||
if not bin_path:
|
||||
raise FileNotFoundError(
|
||||
f"Keine kompilierte Binary für {name} gefunden. Gesuchte Pfade: {candidate_paths}"
|
||||
)
|
||||
|
||||
depends = arch_meta.get("depends", ["gcc-libs", "glibc"])
|
||||
optdepends = arch_meta.get("optdepends", [])
|
||||
|
||||
with tempfile.TemporaryDirectory() as build_dir:
|
||||
bin_dir = os.path.join(build_dir, "usr/bin")
|
||||
doc_dir = os.path.join(build_dir, f"usr/share/doc/{name}")
|
||||
lic_dir = os.path.join(build_dir, f"usr/share/licenses/{name}")
|
||||
os.makedirs(bin_dir, exist_ok=True)
|
||||
os.makedirs(doc_dir, exist_ok=True)
|
||||
os.makedirs(lic_dir, exist_ok=True)
|
||||
|
||||
subprocess.run(["install", "-m", "755", bin_path, f"{bin_dir}/{name}"], check=True)
|
||||
if os.path.exists("LICENSE"):
|
||||
subprocess.run(["install", "-m", "644", "LICENSE", f"{lic_dir}/LICENSE"], check=True)
|
||||
if os.path.exists("README.md"):
|
||||
subprocess.run(["install", "-m", "644", "README.md", f"{doc_dir}/README.md"], check=True)
|
||||
|
||||
systemd_src_dir = "packaging/systemd"
|
||||
all_units, installable_units = collect_systemd_units(systemd_src_dir)
|
||||
if all_units:
|
||||
unit_dir = os.path.join(build_dir, "usr/lib/systemd/system")
|
||||
os.makedirs(unit_dir, exist_ok=True)
|
||||
for unit in all_units:
|
||||
subprocess.run(
|
||||
["install", "-m", "644", os.path.join(systemd_src_dir, unit), os.path.join(unit_dir, unit)],
|
||||
check=True,
|
||||
)
|
||||
|
||||
installed_size = subprocess.check_output(["du", "-sb", build_dir]).decode().split()[0]
|
||||
builddate = str(int(time.time()))
|
||||
|
||||
pkginfo_lines = [
|
||||
f"pkgname = {name}",
|
||||
f"pkgbase = {name}",
|
||||
f"pkgver = {version}-{pkgrel}",
|
||||
f"pkgdesc = {description}",
|
||||
f"url = {repository}",
|
||||
f"builddate = {builddate}",
|
||||
f"packager = {author}",
|
||||
f"size = {installed_size}",
|
||||
f"arch = {arch}",
|
||||
f"license = {license_name}",
|
||||
]
|
||||
for dep in depends:
|
||||
pkginfo_lines.append(f"depend = {dep}")
|
||||
for optdep in optdepends:
|
||||
pkginfo_lines.append(f"optdepend = {optdep}")
|
||||
if all_units:
|
||||
pkginfo_lines.append(f"install = {name}.install")
|
||||
pkginfo_lines.append("makepkgopt = strip\n")
|
||||
|
||||
with open(os.path.join(build_dir, ".PKGINFO"), "w") as f:
|
||||
f.write("\n".join(pkginfo_lines))
|
||||
|
||||
tar_members = [".PKGINFO", "usr"]
|
||||
if all_units:
|
||||
install_script_name = f"{name}.install"
|
||||
with open(os.path.join(build_dir, install_script_name), "w") as f:
|
||||
f.write(build_install_scriptlet(installable_units, all_units))
|
||||
tar_members.append(install_script_name)
|
||||
|
||||
os.makedirs("target/arch", exist_ok=True)
|
||||
output_file = os.path.abspath(f"target/arch/{name}-{version}-{pkgrel}-{arch}.pkg.tar.zst")
|
||||
subprocess.run(["tar", "--zstd", "-cf", output_file, *tar_members], cwd=build_dir, check=True)
|
||||
print(f"Arch-Paket erfolgreich erstellt: {output_file}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Erstellt Arch Linux-Pakete (.pkg.tar.zst)")
|
||||
parser.add_argument("--target", help="Rust Target-Triple (z.B. x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu)")
|
||||
parser.add_argument("--arch", help="Architektur (z.B. x86_64, aarch64)")
|
||||
parser.add_argument("--pkgrel", help="Release-/Build-Nummer (z.B. 1, 2, ...)")
|
||||
args = parser.parse_args()
|
||||
|
||||
build_package(target_triple=args.target, target_arch=args.arch, pkgrel=args.pkgrel)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,312 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Erstellt oder kommentiert ein Gitea-Issue mit den Ergebnissen der Security-Scans.
|
||||
|
||||
Sucht ein offenes Issue mit dem Label ISSUE_LABEL (Standard: "security-scan").
|
||||
Existiert eines, wird der aktuelle Scan-Stand als neuer Kommentar angehängt
|
||||
(die Historie bleibt erhalten). Existiert keines (z.B. weil das letzte
|
||||
geschlossen wurde), wird ein neues Issue erstellt. Gibt es keine Funde mehr,
|
||||
wird ein offenes Issue nur kommentiert, nicht geschlossen.
|
||||
|
||||
Titel und Label lassen sich per Umgebungsvariable ISSUE_TITLE / ISSUE_LABEL
|
||||
überschreiben, damit z.B. TruffleHog-Funde in ein eigenes Issue laufen statt
|
||||
in das gemeinsame Trivy/OSV-Issue.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
LABEL_NAME = os.environ.get("ISSUE_LABEL", "security-scan")
|
||||
LABEL_COLOR = "#b60205"
|
||||
ISSUE_TITLE = os.environ.get("ISSUE_TITLE", "Security-Scan: Offene Schwachstellen")
|
||||
|
||||
SEVERITY_ORDER = {
|
||||
"VERIFIED": -1,
|
||||
"CRITICAL": 0,
|
||||
"HIGH": 1,
|
||||
"MEDIUM": 2,
|
||||
"LOW": 3,
|
||||
"UNKNOWN": 4,
|
||||
"UNVERIFIED": 6,
|
||||
}
|
||||
|
||||
|
||||
def api(method, path, token, gitea_url, data=None):
|
||||
url = f"{gitea_url}/api/v1{path}"
|
||||
body = json.dumps(data).encode() if data is not None else None
|
||||
req = urllib.request.Request(url, data=body, method=method)
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw) if raw else None
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"Gitea API Fehler ({method} {path}): {e.code} {e.read().decode()}", file=sys.stderr)
|
||||
raise
|
||||
|
||||
|
||||
def make_finding(source, id, severity, package, installed="-", fixed="-", target="-"):
|
||||
return {
|
||||
"source": source,
|
||||
"id": id,
|
||||
"severity": severity,
|
||||
"package": package,
|
||||
"installed": installed,
|
||||
"fixed": fixed,
|
||||
"target": target,
|
||||
}
|
||||
|
||||
|
||||
def cvss_score_to_severity(score):
|
||||
try:
|
||||
score = float(score)
|
||||
except (TypeError, ValueError):
|
||||
return "UNKNOWN"
|
||||
if score >= 9.0:
|
||||
return "CRITICAL"
|
||||
if score >= 7.0:
|
||||
return "HIGH"
|
||||
if score >= 4.0:
|
||||
return "MEDIUM"
|
||||
if score > 0.0:
|
||||
return "LOW"
|
||||
return "UNKNOWN"
|
||||
|
||||
|
||||
def load_trivy(path):
|
||||
findings = []
|
||||
if not path or not os.path.isfile(path):
|
||||
return findings
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
for result in data.get("Results", []) or []:
|
||||
target = result.get("Target", "?")
|
||||
for vuln in result.get("Vulnerabilities", []) or []:
|
||||
findings.append(make_finding(
|
||||
"Trivy",
|
||||
vuln.get("VulnerabilityID", "?"),
|
||||
vuln.get("Severity", "UNKNOWN"),
|
||||
vuln.get("PkgName", "?"),
|
||||
installed=vuln.get("InstalledVersion", "?"),
|
||||
fixed=vuln.get("FixedVersion") or "-",
|
||||
target=target,
|
||||
))
|
||||
for misc in result.get("Misconfigurations", []) or []:
|
||||
findings.append(make_finding(
|
||||
"Trivy (Misconfig)",
|
||||
misc.get("ID", "?"),
|
||||
misc.get("Severity", "UNKNOWN"),
|
||||
misc.get("Title", "?"),
|
||||
target=target,
|
||||
))
|
||||
for secret in result.get("Secrets", []) or []:
|
||||
findings.append(make_finding(
|
||||
"Trivy (Secret)",
|
||||
secret.get("RuleID", "?"),
|
||||
secret.get("Severity", "UNKNOWN"),
|
||||
secret.get("Title", "?"),
|
||||
target=target,
|
||||
))
|
||||
return findings
|
||||
|
||||
|
||||
def load_osv(path):
|
||||
findings = []
|
||||
if not path or not os.path.isfile(path):
|
||||
return findings
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
for result in data.get("results", []) or []:
|
||||
source = (result.get("source") or {}).get("path", "?")
|
||||
for pkg in result.get("packages", []) or []:
|
||||
info = pkg.get("package", {})
|
||||
pkg_name = f"{info.get('name', '?')} ({info.get('ecosystem', '?')})"
|
||||
severity_by_id = {}
|
||||
for group in pkg.get("groups", []) or []:
|
||||
label = cvss_score_to_severity(group.get("max_severity"))
|
||||
for vuln_id in group.get("ids", []) or []:
|
||||
severity_by_id[vuln_id] = label
|
||||
for vuln in pkg.get("vulnerabilities", []) or []:
|
||||
vuln_id = vuln.get("id", "?")
|
||||
findings.append(make_finding(
|
||||
"OSV-Scanner",
|
||||
vuln_id,
|
||||
severity_by_id.get(vuln_id, "UNKNOWN"),
|
||||
pkg_name,
|
||||
installed=info.get("version", "?"),
|
||||
target=source,
|
||||
))
|
||||
return findings
|
||||
|
||||
|
||||
def load_trufflehog(path):
|
||||
findings = []
|
||||
if not path or not os.path.isfile(path):
|
||||
return findings
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
git_meta = ((entry.get("SourceMetadata") or {}).get("Data") or {}).get("Git") or {}
|
||||
findings.append(make_finding(
|
||||
"TruffleHog",
|
||||
entry.get("DetectorName", "?"),
|
||||
"VERIFIED" if entry.get("Verified") else "UNVERIFIED",
|
||||
git_meta.get("file", "?"),
|
||||
target=git_meta.get("commit", "-"),
|
||||
))
|
||||
return findings
|
||||
|
||||
|
||||
def sort_findings(findings):
|
||||
return sorted(findings, key=lambda f: (SEVERITY_ORDER.get(f["severity"], 9), f["id"]))
|
||||
|
||||
|
||||
def print_summary(findings):
|
||||
if not findings:
|
||||
print("Keine Funde.")
|
||||
return
|
||||
widths = {
|
||||
key: max(len(key), *(len(str(f[key])) for f in findings))
|
||||
for key in ("source", "id", "severity", "package", "installed", "fixed", "target")
|
||||
}
|
||||
header = ("source", "id", "severity", "package", "installed", "fixed", "target")
|
||||
row_fmt = " ".join(f"{{:{widths[k]}}}" for k in header)
|
||||
print(row_fmt.format(*header))
|
||||
print(row_fmt.format(*("-" * widths[k] for k in header)))
|
||||
for f in findings:
|
||||
print(row_fmt.format(*(str(f[k]) for k in header)))
|
||||
|
||||
|
||||
TRUFFLEHOG_GUIDANCE = """### Vorgehen bei gefundenen Secrets
|
||||
|
||||
1. **Sofort rotieren/widerrufen**: Das betroffene Secret (Token, Passwort, Schlüssel) beim jeweiligen Dienst ungültig machen und durch ein neues ersetzen. Ein einmal committetes Secret gilt als kompromittiert, auch wenn es später aus der Historie entfernt wird.
|
||||
2. **Ursache beheben**: Neues Secret nur noch über Umgebungsvariablen/Secrets-Store einbinden, nicht erneut hart codieren.
|
||||
3. **Historie bereinigen (optional, manuell, erst nach Schritt 1)**: Mit `git filter-repo` oder BFG Repo-Cleaner den Commit-Inhalt entfernen, danach `git push --force` in Absprache mit allen Mitwirkenden – bestehende Clones/Forks werden dadurch ungültig.
|
||||
4. **Issue schließen**, sobald rotiert wurde. TruffleHog findet das alte Secret ggf. weiterhin in der Historie – nach der Rotation ist das unkritisch.
|
||||
|
||||
> Der Wert des Secrets selbst wird hier bewusst nicht ausgegeben, auch nicht gekürzt – nur Detector, Datei und Commit. Fund lässt sich über "Ziel" (Commit-Hash) und "Paket" (Dateipfad) lokalisieren."""
|
||||
|
||||
|
||||
def escape_md_cell(value):
|
||||
return str(value).replace("|", "\\|").replace("\r", " ").replace("\n", " ")
|
||||
|
||||
|
||||
def build_report(findings, run_url):
|
||||
lines = [
|
||||
"Automatisch erstellt vom Security-Scan-Workflow.",
|
||||
f"Lauf: {run_url}" if run_url else "",
|
||||
"",
|
||||
"| Quelle | ID | Schweregrad | Paket | Installiert | Fix | Ziel |",
|
||||
"|---|---|---|---|---|---|---|",
|
||||
]
|
||||
for f in findings:
|
||||
cells = (f["source"], f["id"], f["severity"], f["package"], f["installed"], f["fixed"], f["target"])
|
||||
lines.append("| " + " | ".join(escape_md_cell(c) for c in cells) + " |")
|
||||
if any(f["source"] == "TruffleHog" for f in findings):
|
||||
lines.append("")
|
||||
lines.append(TRUFFLEHOG_GUIDANCE)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def ensure_label(token, gitea_url, repo):
|
||||
page = 1
|
||||
while True:
|
||||
labels = api("GET", f"/repos/{repo}/labels?limit=50&page={page}", token, gitea_url) or []
|
||||
for label in labels:
|
||||
if label.get("name") == LABEL_NAME:
|
||||
return label["id"]
|
||||
if len(labels) < 50:
|
||||
break
|
||||
page += 1
|
||||
created = api("POST", f"/repos/{repo}/labels", token, gitea_url, {
|
||||
"name": LABEL_NAME,
|
||||
"color": LABEL_COLOR,
|
||||
"description": "Automatisch verwaltet vom Security-Scan-Workflow",
|
||||
})
|
||||
return created["id"]
|
||||
|
||||
|
||||
def find_open_issue(token, gitea_url, repo):
|
||||
issues = api(
|
||||
"GET",
|
||||
f"/repos/{repo}/issues?state=open&type=issues&labels={LABEL_NAME}",
|
||||
token,
|
||||
gitea_url,
|
||||
) or []
|
||||
for issue in issues:
|
||||
if issue.get("title") == ISSUE_TITLE:
|
||||
return issue
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
trivy_path = sys.argv[1] if len(sys.argv) > 1 else None
|
||||
osv_path = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
trufflehog_path = sys.argv[3] if len(sys.argv) > 3 else None
|
||||
|
||||
gitea_url = os.environ.get("GITEA_URL", "").strip().rstrip("/")
|
||||
repo = os.environ.get("REPO", "").strip()
|
||||
token = os.environ.get("TOKEN", "").strip()
|
||||
run_url = os.environ.get("RUN_URL", "")
|
||||
osv_exit = int(os.environ.get("OSV_EXIT", "0"))
|
||||
trufflehog_exit = int(os.environ.get("TRUFFLEHOG_EXIT", "0"))
|
||||
|
||||
findings = sort_findings(load_trivy(trivy_path) + load_osv(osv_path) + load_trufflehog(trufflehog_path))
|
||||
print_summary(findings)
|
||||
|
||||
if not token or not gitea_url or not repo:
|
||||
missing = [name for name, val in [("TOKEN", token), ("GITEA_URL", gitea_url), ("REPO", repo)] if not val]
|
||||
print(f"{', '.join(missing)} nicht gesetzt oder leer – überspringe Gitea-Issue-Synchronisation.")
|
||||
else:
|
||||
open_issue = find_open_issue(token, gitea_url, repo)
|
||||
|
||||
if findings:
|
||||
report = build_report(findings, run_url)
|
||||
if open_issue:
|
||||
print(f"Kommentiere bestehendes Issue #{open_issue['number']} mit {len(findings)} Fund(en).")
|
||||
api("POST", f"/repos/{repo}/issues/{open_issue['number']}/comments", token, gitea_url, {"body": report})
|
||||
else:
|
||||
label_id = ensure_label(token, gitea_url, repo)
|
||||
print(f"Erstelle neues Issue mit {len(findings)} Fund(en).")
|
||||
api("POST", f"/repos/{repo}/issues", token, gitea_url, {
|
||||
"title": ISSUE_TITLE,
|
||||
"body": report,
|
||||
"labels": [label_id],
|
||||
})
|
||||
elif open_issue:
|
||||
print(f"Keine aktuellen Funde mehr. Kommentiere Issue #{open_issue['number']}.")
|
||||
api("POST", f"/repos/{repo}/issues/{open_issue['number']}/comments", token, gitea_url, {
|
||||
"body": f"Aktueller Scan hat keine offenen Schwachstellen mehr gefunden.\n\n{run_url}".strip(),
|
||||
})
|
||||
else:
|
||||
print("Keine Funde und kein offenes Issue vorhanden.")
|
||||
|
||||
osv_ok_exits = {0, 1}
|
||||
trufflehog_ok_exits = {0, 183}
|
||||
if osv_exit not in osv_ok_exits:
|
||||
print(f"WARNUNG: osv-scanner beendete sich mit unerwartetem Exit-Code {osv_exit} - Scan evtl. unvollständig.", file=sys.stderr)
|
||||
if trufflehog_exit not in trufflehog_ok_exits:
|
||||
print(f"WARNUNG: trufflehog beendete sich mit unerwartetem Exit-Code {trufflehog_exit} - Scan evtl. unvollständig.", file=sys.stderr)
|
||||
|
||||
has_trivy_findings = any(f["source"].startswith("Trivy") for f in findings)
|
||||
if (
|
||||
has_trivy_findings
|
||||
or osv_exit == 1
|
||||
or trufflehog_exit == 183
|
||||
or osv_exit not in osv_ok_exits
|
||||
or trufflehog_exit not in trufflehog_ok_exits
|
||||
):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,50 @@
|
||||
//! `smart-mount doctor` - prüft die im Laufe der Entwicklung angesammelten Voraussetzungen
|
||||
//! (Binaries, Gruppenmitgliedschaft, fstab-Setup, Scheduler) gebündelt an einer Stelle.
|
||||
|
||||
use smart_mount::config;
|
||||
use smart_mount::doctor::{self, CheckStatus};
|
||||
|
||||
pub async fn run(json: bool) -> anyhow::Result<()> {
|
||||
let cfg = config::pairs::load()?;
|
||||
let results = doctor::run_checks(&cfg);
|
||||
|
||||
if json {
|
||||
#[derive(serde::Serialize)]
|
||||
struct JsonResult<'a> {
|
||||
label: &'a str,
|
||||
status: &'static str,
|
||||
detail: &'a str,
|
||||
}
|
||||
let json_results: Vec<JsonResult> = results
|
||||
.iter()
|
||||
.map(|r| JsonResult {
|
||||
label: &r.label,
|
||||
status: match r.status {
|
||||
CheckStatus::Ok => "ok",
|
||||
CheckStatus::Warn => "warn",
|
||||
CheckStatus::Fail => "fail",
|
||||
},
|
||||
detail: &r.detail,
|
||||
})
|
||||
.collect();
|
||||
println!("{}", serde_json::to_string_pretty(&json_results)?);
|
||||
} else {
|
||||
for r in &results {
|
||||
let symbol = match r.status {
|
||||
CheckStatus::Ok => "[ok] ",
|
||||
CheckStatus::Warn => "[warn]",
|
||||
CheckStatus::Fail => "[FAIL]",
|
||||
};
|
||||
println!("{symbol} {}: {}", r.label, r.detail);
|
||||
}
|
||||
}
|
||||
|
||||
let failures = results
|
||||
.iter()
|
||||
.filter(|r| r.status == CheckStatus::Fail)
|
||||
.count();
|
||||
if failures > 0 {
|
||||
anyhow::bail!("{failures} problem(s) found");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,737 @@
|
||||
//! `smart-mount drive add|edit|remove|list`.
|
||||
//!
|
||||
//! `add`/`edit` unterstützen zwei Modi: interaktiv (Standard, `dialoguer`-Prompts, mit
|
||||
//! bereits per Flag/`edit` vorhandenen Werten als Vorbelegung) und nicht-interaktiv
|
||||
//! (`--non-interactive`, für Skripte/Automatisierung - fehlende Pflichtfelder sind dann ein
|
||||
//! Fehler statt eines Prompts, der ohne TTY ohnehin fehlschlagen würde).
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
|
||||
use clap::{Args, Subcommand};
|
||||
use dialoguer::{Confirm, Input, Password, Select};
|
||||
|
||||
use smart_mount::config::{
|
||||
self, AppConfig, CloudSide, DrivePair, LocalAddress, LocalSide, MountKind,
|
||||
};
|
||||
use smart_mount::db::credentials::{Credential, CredentialStore, Side};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum DriveAction {
|
||||
/// Create a new drive pair (interactive, unless all fields are given via flags).
|
||||
Add(DriveArgs),
|
||||
/// Edit an existing drive pair - only the given fields change.
|
||||
Edit {
|
||||
id: String,
|
||||
#[command(flatten)]
|
||||
args: DriveArgs,
|
||||
},
|
||||
/// Remove an existing drive pair.
|
||||
Remove { id: String },
|
||||
/// List all configured drive pairs.
|
||||
List {
|
||||
/// Output as JSON instead of a table - for scripts.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Args, Default)]
|
||||
pub struct DriveArgs {
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
/// Where the pair's symlink/backing directories live. Default (on 'add', derived from the
|
||||
/// name): '<mount_base_dir>/<name>' (see 'settings.mount_base_dir'). Ignored on 'edit'
|
||||
/// unless explicitly given - an existing pair's mount point stays fixed otherwise, so an
|
||||
/// active mount doesn't get orphaned.
|
||||
#[arg(long)]
|
||||
mount_point: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
owner_user: Option<String>,
|
||||
|
||||
#[arg(long, value_enum)]
|
||||
local_kind: Option<MountKind>,
|
||||
#[arg(long, conflicts_with = "local_mac")]
|
||||
local_ip: Option<Ipv4Addr>,
|
||||
#[arg(long, conflicts_with = "local_ip")]
|
||||
local_mac: Option<String>,
|
||||
#[arg(long)]
|
||||
local_share: Option<String>,
|
||||
#[arg(long)]
|
||||
local_username: Option<String>,
|
||||
/// Insecure (visible in process listing/shell history) - prefer
|
||||
/// `--local-password-stdin` for scripts.
|
||||
#[arg(long, conflicts_with = "local_password_stdin")]
|
||||
local_password: Option<String>,
|
||||
/// Reads the local password as one line from stdin instead of passing it as an argument.
|
||||
#[arg(long)]
|
||||
local_password_stdin: bool,
|
||||
|
||||
#[arg(long, value_enum)]
|
||||
cloud_kind: Option<MountKind>,
|
||||
#[arg(long)]
|
||||
cloud_host: Option<String>,
|
||||
#[arg(long)]
|
||||
cloud_share: Option<String>,
|
||||
#[arg(long)]
|
||||
cloud_username: Option<String>,
|
||||
/// Insecure (visible in process listing/shell history) - prefer
|
||||
/// `--cloud-password-stdin` for scripts.
|
||||
#[arg(long, conflicts_with = "cloud_password_stdin")]
|
||||
cloud_password: Option<String>,
|
||||
/// Reads the cloud password as one line from stdin instead of passing it as an argument.
|
||||
#[arg(long)]
|
||||
cloud_password_stdin: bool,
|
||||
|
||||
/// Does not prompt for anything interactively - missing required fields cause an error
|
||||
/// instead of a prompt (which would fail anyway without a terminal). For scripts/automation.
|
||||
#[arg(long)]
|
||||
non_interactive: bool,
|
||||
}
|
||||
|
||||
pub async fn run(action: DriveAction) -> anyhow::Result<()> {
|
||||
match action {
|
||||
DriveAction::Add(args) => add(args).await,
|
||||
DriveAction::Edit { id, args } => edit(&id, args).await,
|
||||
DriveAction::Remove { id } => remove(&id).await,
|
||||
DriveAction::List { json } => list(json).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(json: bool) -> anyhow::Result<()> {
|
||||
let cfg: AppConfig = config::pairs::load()?;
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&cfg.pairs)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if cfg.pairs.is_empty() {
|
||||
println!("No drive pairs configured.");
|
||||
return Ok(());
|
||||
}
|
||||
for pair in &cfg.pairs {
|
||||
println!(
|
||||
"{} \"{}\" lokal={} cloud={} -> {}",
|
||||
pair.id,
|
||||
pair.name,
|
||||
pair.local.kind.as_str(),
|
||||
pair.cloud.kind.as_str(),
|
||||
pair.mount_point.display()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove(id: &str) -> anyhow::Result<()> {
|
||||
crate::cli::require_root("drive remove")?;
|
||||
|
||||
// Vor dem Entfernen aus der Config nachschlagen, damit wir hinterher noch wissen, welche
|
||||
// Mount-Typen/Adressen betroffen sind - nötig, um die passenden Klartext-Zugangsdaten
|
||||
// (davfs2 secrets, .cred-Datei) aufzuräumen, siehe smart_mount::mount::cleanup_credentials.
|
||||
let cfg = config::pairs::load()?;
|
||||
let pair = config::pairs::find_pair(&cfg, id)?;
|
||||
|
||||
// Best-effort wie `cleanup_credentials` weiter unten: ein Unmount-Fehler (z. B. weil das
|
||||
// Paar von einem anderen Nutzer/Kontext gemountet wurde und wir keine Berechtigung haben)
|
||||
// darf das Entfernen aus der Konfiguration nicht blockieren - sonst käme der Nutzer nicht
|
||||
// mehr an sein eigenes `drive remove` heran, ohne vorher erst als root/mit den richtigen
|
||||
// Rechten manuell auszuhängen.
|
||||
if let Err(e) = smart_mount::reconcile::unmount_pair(&pair, &cfg.settings).await {
|
||||
logger_ctdra::warn(
|
||||
"drive",
|
||||
&format!("could not unmount drive pair '{id}' before removal: {e}"),
|
||||
);
|
||||
}
|
||||
config::pairs::remove_pair(id)?;
|
||||
let creds = CredentialStore::open().await?;
|
||||
creds.delete(id, None).await?;
|
||||
smart_mount::mount::cleanup_credentials(&pair, &cfg.settings);
|
||||
|
||||
println!("Drive pair '{id}' removed.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add(args: DriveArgs) -> anyhow::Result<()> {
|
||||
crate::cli::require_root("drive add")?;
|
||||
let ni = args.non_interactive;
|
||||
|
||||
let name = resolve_field(args.name, None, "Drive pair name", ni, true)?.expect("required");
|
||||
// Der Nutzer, der das Paar einrichtet, bekommt standardmäßig vollen Zugriff darauf (siehe
|
||||
// `invoking_user`) - überschreibbar per '--owner-user' oder (interaktiv) durch Ablehnen/
|
||||
// einen anderen Namen im Prompt.
|
||||
let owner_user = resolve_owner_user(args.owner_user, invoking_user().as_deref(), ni)?;
|
||||
|
||||
let local_kind = resolve_kind(args.local_kind, None, "Local mount type", ni)?;
|
||||
let local_address = resolve_local_address(args.local_ip, args.local_mac, None, ni)?;
|
||||
let local_share = resolve_field(args.local_share, None, "Local share/export path", ni, true)?
|
||||
.expect("required");
|
||||
let local_password_flag = read_password_flag(args.local_password, args.local_password_stdin)?;
|
||||
let (local_username, local_password) = resolve_credentials(
|
||||
local_kind,
|
||||
args.local_username,
|
||||
local_password_flag,
|
||||
None,
|
||||
ni,
|
||||
"Local credentials",
|
||||
)?;
|
||||
|
||||
let cloud_kind = resolve_kind(args.cloud_kind, None, "Cloud mount type", ni)?;
|
||||
let cloud_host = resolve_field(args.cloud_host, None, "Cloud server address/URL", ni, true)?
|
||||
.expect("required");
|
||||
let cloud_share = resolve_field(args.cloud_share, None, "Cloud share/export path", ni, true)?
|
||||
.expect("required");
|
||||
let cloud_password_flag = read_password_flag(args.cloud_password, args.cloud_password_stdin)?;
|
||||
let (cloud_username, cloud_password) = resolve_credentials(
|
||||
cloud_kind,
|
||||
args.cloud_username,
|
||||
cloud_password_flag,
|
||||
None,
|
||||
ni,
|
||||
"Cloud credentials",
|
||||
)?;
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
// Bewusst KEIN `unwrap_or_default()`: ein Ladefehler bedeutet eine kaputte/korrupte
|
||||
// Konfigurationsdatei (eine fehlende Datei liefert bereits Standardwerte, siehe
|
||||
// `config_ctdra::load`), nicht "noch keine Konfiguration vorhanden". Stillschweigend mit
|
||||
// `GlobalSettings::default()` weiterzumachen würde nicht nur einen ggf. abweichenden
|
||||
// `mount_base_dir` ignorieren, sondern - schwerwiegender - auch das nachfolgende
|
||||
// `add_pair()` (das intern selbst frisch lädt) auf denselben kaputten Zustand treffen
|
||||
// lassen, was dort ALLE bestehenden Paare durch die eine neue Konfiguration ersetzen
|
||||
// würde. Besser früh mit einem klaren Fehler abbrechen.
|
||||
let cfg = config::pairs::load()?;
|
||||
let default_mount_point = default_mount_point(&cfg.settings.mount_base_dir, &name, &cfg.pairs);
|
||||
let mount_point = resolve_mount_point(args.mount_point, &default_mount_point, ni)?;
|
||||
|
||||
let pair = DrivePair {
|
||||
id: id.clone(),
|
||||
name,
|
||||
enabled: true,
|
||||
owner_user,
|
||||
mount_point,
|
||||
local: LocalSide {
|
||||
kind: local_kind,
|
||||
address: local_address,
|
||||
share: local_share,
|
||||
username: local_username.clone(),
|
||||
extra_options: vec![],
|
||||
},
|
||||
cloud: CloudSide {
|
||||
kind: cloud_kind,
|
||||
host_or_url: cloud_host,
|
||||
share: cloud_share,
|
||||
username: cloud_username.clone(),
|
||||
extra_options: vec![],
|
||||
},
|
||||
};
|
||||
|
||||
config::pairs::add_pair(pair)?;
|
||||
|
||||
let creds = CredentialStore::open().await?;
|
||||
if let Some(pw) = local_password {
|
||||
creds
|
||||
.put(&id, Side::Local, local_username.as_deref(), None, &pw)
|
||||
.await?;
|
||||
}
|
||||
if let Some(pw) = cloud_password {
|
||||
creds
|
||||
.put(&id, Side::Cloud, cloud_username.as_deref(), None, &pw)
|
||||
.await?;
|
||||
}
|
||||
|
||||
println!("Drive pair '{id}' created.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn edit(id: &str, args: DriveArgs) -> anyhow::Result<()> {
|
||||
crate::cli::require_root("drive edit")?;
|
||||
let cfg = config::pairs::load()?;
|
||||
let existing = config::pairs::find_pair(&cfg, id)?;
|
||||
let ni = args.non_interactive;
|
||||
|
||||
let name = resolve_field(args.name, Some(&existing.name), "Drive pair name", ni, true)?
|
||||
.expect("required");
|
||||
let owner_user = resolve_owner_user(args.owner_user, existing.owner_user.as_deref(), ni)?;
|
||||
|
||||
let local_kind = resolve_kind(
|
||||
args.local_kind,
|
||||
Some(existing.local.kind),
|
||||
"Local mount type",
|
||||
ni,
|
||||
)?;
|
||||
let local_address = resolve_local_address(
|
||||
args.local_ip,
|
||||
args.local_mac,
|
||||
Some(&existing.local.address),
|
||||
ni,
|
||||
)?;
|
||||
let local_share = resolve_field(
|
||||
args.local_share,
|
||||
Some(&existing.local.share),
|
||||
"Local share/export path",
|
||||
ni,
|
||||
true,
|
||||
)?
|
||||
.expect("required");
|
||||
|
||||
let creds = CredentialStore::open().await?;
|
||||
let current_local_cred = creds.get(id, Side::Local).await?;
|
||||
let local_password_flag = read_password_flag(args.local_password, args.local_password_stdin)?;
|
||||
let (local_username, local_password) = resolve_credentials(
|
||||
local_kind,
|
||||
args.local_username,
|
||||
local_password_flag,
|
||||
current_local_cred.as_ref(),
|
||||
ni,
|
||||
"Local credentials",
|
||||
)?;
|
||||
|
||||
let cloud_kind = resolve_kind(
|
||||
args.cloud_kind,
|
||||
Some(existing.cloud.kind),
|
||||
"Cloud mount type",
|
||||
ni,
|
||||
)?;
|
||||
let cloud_host = resolve_field(
|
||||
args.cloud_host,
|
||||
Some(&existing.cloud.host_or_url),
|
||||
"Cloud server address/URL",
|
||||
ni,
|
||||
true,
|
||||
)?
|
||||
.expect("required");
|
||||
let cloud_share = resolve_field(
|
||||
args.cloud_share,
|
||||
Some(&existing.cloud.share),
|
||||
"Cloud share/export path",
|
||||
ni,
|
||||
true,
|
||||
)?
|
||||
.expect("required");
|
||||
let current_cloud_cred = creds.get(id, Side::Cloud).await?;
|
||||
let cloud_password_flag = read_password_flag(args.cloud_password, args.cloud_password_stdin)?;
|
||||
let (cloud_username, cloud_password) = resolve_credentials(
|
||||
cloud_kind,
|
||||
args.cloud_username,
|
||||
cloud_password_flag,
|
||||
current_cloud_cred.as_ref(),
|
||||
ni,
|
||||
"Cloud credentials",
|
||||
)?;
|
||||
|
||||
// Anders als bei allen anderen Feldern gibt es hier bewusst KEINEN interaktiven Prompt: der
|
||||
// Mountpoint (und damit die Backing-Verzeichnisse) bleibt standardmäßig unverändert, sonst
|
||||
// würde ein aktiver Mount verwaisen. Nur eine explizite '--mount-point'-Angabe ändert ihn.
|
||||
let mount_point = match args.mount_point {
|
||||
Some(p) => {
|
||||
if smart_mount::mount::target::active_side(&existing).is_some() {
|
||||
println!(
|
||||
"Warning: pair '{id}' appears to be actively mounted - the old backing \
|
||||
directories will be orphaned. Run 'sudo smart-mount unmount --name {id}' \
|
||||
first, then 'mount --name {id}' again after this edit."
|
||||
);
|
||||
}
|
||||
p
|
||||
}
|
||||
None => existing.mount_point.clone(),
|
||||
};
|
||||
|
||||
let updated = DrivePair {
|
||||
id: id.to_string(),
|
||||
name,
|
||||
enabled: existing.enabled,
|
||||
owner_user,
|
||||
mount_point,
|
||||
local: LocalSide {
|
||||
kind: local_kind,
|
||||
address: local_address,
|
||||
share: local_share,
|
||||
username: local_username.clone(),
|
||||
extra_options: existing.local.extra_options.clone(),
|
||||
},
|
||||
cloud: CloudSide {
|
||||
kind: cloud_kind,
|
||||
host_or_url: cloud_host,
|
||||
share: cloud_share,
|
||||
username: cloud_username.clone(),
|
||||
extra_options: existing.cloud.extra_options.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
config::pairs::update_pair(updated)?;
|
||||
|
||||
if let Some(pw) = local_password {
|
||||
creds
|
||||
.put(id, Side::Local, local_username.as_deref(), None, &pw)
|
||||
.await?;
|
||||
}
|
||||
if let Some(pw) = cloud_password {
|
||||
creds
|
||||
.put(id, Side::Cloud, cloud_username.as_deref(), None, &pw)
|
||||
.await?;
|
||||
}
|
||||
|
||||
println!("Drive pair '{id}' updated.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Liest ein Passwort entweder von stdin (eine Zeile, `\r`/`\n` abgeschnitten) oder gibt das
|
||||
/// per Flag übergebene zurück.
|
||||
fn read_password_flag(flag: Option<String>, stdin: bool) -> anyhow::Result<Option<String>> {
|
||||
if stdin {
|
||||
let mut buf = String::new();
|
||||
std::io::stdin().read_line(&mut buf)?;
|
||||
Ok(Some(buf.trim_end_matches(['\n', '\r']).to_string()))
|
||||
} else {
|
||||
Ok(flag)
|
||||
}
|
||||
}
|
||||
|
||||
/// Löst ein einzelnes String-Feld auf: Flag > (nicht-interaktiv: `current`, sonst Fehler bei
|
||||
/// Pflichtfeld) > interaktiver Prompt (vorbelegt mit `current`, falls vorhanden).
|
||||
fn resolve_field(
|
||||
flag: Option<String>,
|
||||
current: Option<&str>,
|
||||
label: &str,
|
||||
non_interactive: bool,
|
||||
required: bool,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
if let Some(v) = flag {
|
||||
return Ok(Some(v));
|
||||
}
|
||||
if non_interactive {
|
||||
if required && current.is_none() {
|
||||
anyhow::bail!(
|
||||
"Field '{label}' is missing - specify it via a flag in non-interactive mode."
|
||||
);
|
||||
}
|
||||
return Ok(current.map(str::to_string));
|
||||
}
|
||||
let mut input = Input::<String>::new();
|
||||
input = input.with_prompt(label);
|
||||
if let Some(d) = current {
|
||||
input = input.default(d.to_string());
|
||||
}
|
||||
Ok(Some(input.interact_text()?))
|
||||
}
|
||||
|
||||
/// Wandelt `name` in einen sicheren Verzeichnisnamen um (für den Standard-Mountpoint, siehe
|
||||
/// `default_mount_point`): nur ASCII-alphanumerische Zeichen sowie `-`/`_` bleiben erhalten,
|
||||
/// jede Folge anderer Zeichen (Leerzeichen, `/`, Sonderzeichen, Unicode) wird zu einem einzelnen
|
||||
/// `-`; führende/folgende `-` werden entfernt. Ein leeres Ergebnis (z. B. bei einem rein aus
|
||||
/// Sonderzeichen bestehenden Namen) fällt auf `"pair"` zurück, damit der Mountpoint nie
|
||||
/// leer/unbrauchbar wird.
|
||||
fn sanitize_dir_name(name: &str) -> String {
|
||||
let mut out = String::with_capacity(name.len());
|
||||
for c in name.chars() {
|
||||
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||
out.push(c);
|
||||
} else if !out.ends_with('-') {
|
||||
out.push('-');
|
||||
}
|
||||
}
|
||||
match out.trim_matches('-') {
|
||||
"" => "pair".to_string(),
|
||||
trimmed => trimmed.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard-Mountpoint für ein neues Paar: `<base>/<sanitierter Name>` (siehe
|
||||
/// `sanitize_dir_name`). Namen sind - anders als `id` - nicht zwingend eindeutig (siehe
|
||||
/// `config::pairs::find_pair`); kollidiert der Standard mit dem Mountpoint eines bestehenden
|
||||
/// Paares, wird `-2`, `-3`, ... angehängt, bis er eindeutig ist.
|
||||
fn default_mount_point(base: &Path, name: &str, existing: &[DrivePair]) -> PathBuf {
|
||||
let sanitized = sanitize_dir_name(name);
|
||||
let mut candidate = base.join(&sanitized);
|
||||
let mut suffix = 2;
|
||||
while existing.iter().any(|p| p.mount_point == candidate) {
|
||||
candidate = base.join(format!("{sanitized}-{suffix}"));
|
||||
suffix += 1;
|
||||
}
|
||||
candidate
|
||||
}
|
||||
|
||||
/// Löst den Mountpoint auf: Flag > (interaktiv: Prompt vorbelegt mit `default`, editierbar) >
|
||||
/// (nicht-interaktiv: `default` ungefragt übernehmen).
|
||||
fn resolve_mount_point(
|
||||
flag: Option<PathBuf>,
|
||||
default: &Path,
|
||||
non_interactive: bool,
|
||||
) -> anyhow::Result<PathBuf> {
|
||||
if let Some(p) = flag {
|
||||
return Ok(p);
|
||||
}
|
||||
if non_interactive {
|
||||
return Ok(default.to_path_buf());
|
||||
}
|
||||
let input: String = Input::new()
|
||||
.with_prompt("Mount point")
|
||||
.default(default.display().to_string())
|
||||
.interact_text()?;
|
||||
Ok(PathBuf::from(input))
|
||||
}
|
||||
|
||||
/// Der Nutzer, in dessen Namen `sudo` aufgerufen wurde (falls so aufgerufen) - Standardwert für
|
||||
/// `owner_user` beim Anlegen eines neuen Paares (siehe `add`), damit der einrichtende Nutzer
|
||||
/// ohne extra Angabe vollen Zugriff auf sein eigenes Laufwerkspaar bekommt. `None`, wenn direkt
|
||||
/// als root angemeldet (kein "einrichtender Nutzer" außer root selbst) oder `SUDO_USER` leer/
|
||||
/// nicht gesetzt ist.
|
||||
fn invoking_user() -> Option<String> {
|
||||
std::env::var("SUDO_USER").ok().filter(|u| !u.is_empty())
|
||||
}
|
||||
|
||||
/// Optional: falls gesetzt, bekommt dieser Nutzer bei CIFS/WebDAV vollen Zugriff
|
||||
/// (uid=/gid=/file_mode=0700/dir_mode=0700) statt der sonst üblichen root-Ownership - der
|
||||
/// Mount selbst läuft immer als root (siehe [`smart_mount::systemd`]). `default_owner` ist der
|
||||
/// vorbelegte/vorgeschlagene Wert (bei `add`: der einrichtende Nutzer, siehe `invoking_user`;
|
||||
/// bei `edit`: der bisherige `owner_user`) - immer überschreibbar per Flag, im interaktiven
|
||||
/// Prompt auch durch Ablehnen oder einen anderen Namen.
|
||||
fn resolve_owner_user(
|
||||
flag: Option<String>,
|
||||
default_owner: Option<&str>,
|
||||
non_interactive: bool,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
if let Some(v) = flag {
|
||||
return Ok(Some(v));
|
||||
}
|
||||
if non_interactive {
|
||||
return Ok(default_owner.map(str::to_string));
|
||||
}
|
||||
let prompt = match default_owner {
|
||||
Some(user) => format!(
|
||||
"Should '{user}' get full access to this drive (uid/gid, including script execution)?"
|
||||
),
|
||||
None => "Should a specific user get full access to this drive (uid/gid, including script execution)?".to_string(),
|
||||
};
|
||||
let want_owner = Confirm::new()
|
||||
.with_prompt(prompt)
|
||||
.default(default_owner.is_some())
|
||||
.interact()?;
|
||||
if !want_owner {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut input = Input::<String>::new().with_prompt("Linux username");
|
||||
if let Some(c) = default_owner {
|
||||
input = input.default(c.to_string());
|
||||
}
|
||||
Ok(Some(input.interact_text()?))
|
||||
}
|
||||
|
||||
fn resolve_kind(
|
||||
flag: Option<MountKind>,
|
||||
current: Option<MountKind>,
|
||||
label: &str,
|
||||
non_interactive: bool,
|
||||
) -> anyhow::Result<MountKind> {
|
||||
if let Some(k) = flag {
|
||||
return Ok(k);
|
||||
}
|
||||
if let Some(k) = current
|
||||
&& non_interactive
|
||||
{
|
||||
return Ok(k);
|
||||
}
|
||||
if non_interactive {
|
||||
anyhow::bail!(
|
||||
"Field '{label}' is missing - specify it via a flag in non-interactive mode."
|
||||
);
|
||||
}
|
||||
let default_idx = match current {
|
||||
Some(MountKind::Smb) => 1,
|
||||
Some(MountKind::Nfs) => 2,
|
||||
_ => 0,
|
||||
};
|
||||
let idx = Select::new()
|
||||
.with_prompt(label)
|
||||
.items(["WebDAV", "SMB/CIFS", "NFS"])
|
||||
.default(default_idx)
|
||||
.interact()?;
|
||||
Ok(match idx {
|
||||
0 => MountKind::WebDav,
|
||||
1 => MountKind::Smb,
|
||||
_ => MountKind::Nfs,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_local_address(
|
||||
ip_flag: Option<Ipv4Addr>,
|
||||
mac_flag: Option<String>,
|
||||
current: Option<&LocalAddress>,
|
||||
non_interactive: bool,
|
||||
) -> anyhow::Result<LocalAddress> {
|
||||
if let Some(ip) = ip_flag {
|
||||
return Ok(LocalAddress::Ip(ip));
|
||||
}
|
||||
if let Some(mac) = mac_flag {
|
||||
return Ok(LocalAddress::Mac(mac));
|
||||
}
|
||||
if non_interactive {
|
||||
return current.cloned().ok_or_else(|| {
|
||||
anyhow::anyhow!("Local address is missing - specify '--local-ip' or '--local-mac'.")
|
||||
});
|
||||
}
|
||||
|
||||
let default_idx = usize::from(matches!(current, Some(LocalAddress::Mac(_))));
|
||||
let idx = Select::new()
|
||||
.with_prompt("Local addressing")
|
||||
.items(["IP address", "MAC address (resolved via mac2ip)"])
|
||||
.default(default_idx)
|
||||
.interact()?;
|
||||
|
||||
if idx == 0 {
|
||||
let mut input = Input::<String>::new().with_prompt("IP address");
|
||||
if let Some(LocalAddress::Ip(ip)) = current {
|
||||
input = input.default(ip.to_string());
|
||||
}
|
||||
let ip_str: String = input.interact_text()?;
|
||||
Ok(LocalAddress::Ip(Ipv4Addr::from_str(&ip_str)?))
|
||||
} else {
|
||||
let mut input = Input::<String>::new().with_prompt("MAC address (e.g. aa:bb:cc:dd:ee:ff)");
|
||||
if let Some(LocalAddress::Mac(mac)) = current {
|
||||
input = input.default(mac.clone());
|
||||
}
|
||||
Ok(LocalAddress::Mac(input.interact_text()?))
|
||||
}
|
||||
}
|
||||
|
||||
/// Löst Nutzername/Passwort für eine Seite auf.
|
||||
///
|
||||
/// - Explizit per Flag/stdin gegebenes Passwort wird immer übernommen.
|
||||
/// - Nicht-interaktiv ohne neues Passwort: nur der Nutzername wird ggf. aktualisiert, das
|
||||
/// gespeicherte Passwort bleibt unangetastet (`None` im Rückgabewert = "nicht ändern").
|
||||
/// - Interaktiv beim Bearbeiten (`current.is_some()`): fragt separat, ob das Passwort
|
||||
/// überhaupt geändert werden soll (Standard: nein) - ein Edit erzwingt keine Neueingabe.
|
||||
/// - Interaktiv beim Anlegen: fragt Nutzername+Passwort zusammen ab, sofern der Mount-Typ
|
||||
/// Zugangsdaten braucht (WebDAV/SMB immer, NFS nur auf Wunsch für Kerberos).
|
||||
fn resolve_credentials(
|
||||
kind: MountKind,
|
||||
username_flag: Option<String>,
|
||||
password_flag: Option<String>,
|
||||
current: Option<&Credential>,
|
||||
non_interactive: bool,
|
||||
label: &str,
|
||||
) -> anyhow::Result<(Option<String>, Option<String>)> {
|
||||
let current_username = current.and_then(|c| c.username.clone());
|
||||
|
||||
if let Some(pw) = password_flag {
|
||||
let username = username_flag.or(current_username);
|
||||
return Ok((username, Some(pw)));
|
||||
}
|
||||
|
||||
if non_interactive {
|
||||
return Ok((username_flag.or(current_username), None));
|
||||
}
|
||||
|
||||
let is_edit = current.is_some();
|
||||
let needs = match kind {
|
||||
MountKind::WebDav | MountKind::Smb => true,
|
||||
MountKind::Nfs => Confirm::new()
|
||||
.with_prompt(format!("{label}: Kerberos credentials for NFS?"))
|
||||
.default(is_edit)
|
||||
.interact()?,
|
||||
};
|
||||
if !needs {
|
||||
return Ok((username_flag.or(current_username), None));
|
||||
}
|
||||
|
||||
let mut username_input = Input::<String>::new().with_prompt(format!("{label}: username"));
|
||||
if let Some(u) = username_flag.or(current_username) {
|
||||
username_input = username_input.default(u);
|
||||
}
|
||||
let username: String = username_input.interact_text()?;
|
||||
|
||||
if is_edit {
|
||||
let change_password = Confirm::new()
|
||||
.with_prompt(format!("{label}: change password?"))
|
||||
.default(false)
|
||||
.interact()?;
|
||||
if !change_password {
|
||||
return Ok((Some(username), None));
|
||||
}
|
||||
}
|
||||
|
||||
let password: String = Password::new()
|
||||
.with_prompt(format!("{label}: password"))
|
||||
.with_confirmation("Confirm password", "Passwords do not match")
|
||||
.interact()?;
|
||||
Ok((Some(username), Some(password)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use smart_mount::config::LocalAddress;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
fn sample_pair(mount_point: &str) -> DrivePair {
|
||||
DrivePair {
|
||||
id: "pair-1".into(),
|
||||
name: "Test".into(),
|
||||
enabled: true,
|
||||
owner_user: None,
|
||||
mount_point: PathBuf::from(mount_point),
|
||||
local: LocalSide {
|
||||
kind: MountKind::Nfs,
|
||||
address: LocalAddress::Ip(Ipv4Addr::new(192, 168, 1, 5)),
|
||||
share: "share".into(),
|
||||
username: None,
|
||||
extra_options: vec![],
|
||||
},
|
||||
cloud: CloudSide {
|
||||
kind: MountKind::Nfs,
|
||||
host_or_url: "cloud.example.com".into(),
|
||||
share: "share".into(),
|
||||
username: None,
|
||||
extra_options: vec![],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_dir_name_keeps_simple_names_unchanged() {
|
||||
assert_eq!(sanitize_dir_name("NAS"), "NAS");
|
||||
assert_eq!(sanitize_dir_name("my-nas_2"), "my-nas_2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_dir_name_collapses_runs_of_special_characters_to_one_dash() {
|
||||
assert_eq!(sanitize_dir_name("My NAS / Drive"), "My-NAS-Drive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_dir_name_trims_leading_and_trailing_dashes() {
|
||||
assert_eq!(sanitize_dir_name(" NAS!!"), "NAS");
|
||||
assert_eq!(sanitize_dir_name("/etc/passwd"), "etc-passwd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_dir_name_falls_back_to_pair_for_only_special_characters() {
|
||||
assert_eq!(sanitize_dir_name("!!!"), "pair");
|
||||
assert_eq!(sanitize_dir_name(""), "pair");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_mount_point_uses_base_dir_and_sanitized_name() {
|
||||
let point = default_mount_point(Path::new("/run/media"), "NAS", &[]);
|
||||
assert_eq!(point, PathBuf::from("/run/media/NAS"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_mount_point_dedupes_against_existing_pairs() {
|
||||
let existing = vec![
|
||||
sample_pair("/run/media/NAS"),
|
||||
sample_pair("/run/media/NAS-2"),
|
||||
];
|
||||
let point = default_mount_point(Path::new("/run/media"), "NAS", &existing);
|
||||
assert_eq!(point, PathBuf::from("/run/media/NAS-3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_mount_point_is_unaffected_by_unrelated_existing_pairs() {
|
||||
let existing = vec![sample_pair("/run/media/OtherPair")];
|
||||
let point = default_mount_point(Path::new("/run/media"), "NAS", &existing);
|
||||
assert_eq!(point, PathBuf::from("/run/media/NAS"));
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
//! `clap`-CLI-Definition und Dispatch.
|
||||
|
||||
pub mod doctor;
|
||||
pub mod drive;
|
||||
pub mod mount_cmd;
|
||||
pub mod service;
|
||||
pub mod status;
|
||||
pub mod watch;
|
||||
|
||||
use clap::{CommandFactory, Parser, Subcommand};
|
||||
use clap_complete::Shell;
|
||||
|
||||
/// Bricht mit einer klaren Fehlermeldung ab, falls nicht als root aufgerufen. Mounten läuft
|
||||
/// ausschließlich als root/System-Dienst (siehe [`smart_mount::systemd`]) - es gibt seit dem
|
||||
/// Wegfall des Nutzerkontexts keine unprivilegierte Mount-Variante mehr, für die ein
|
||||
/// Rechte-Check hier zu früh käme.
|
||||
pub(crate) fn require_root(context: &str) -> anyhow::Result<()> {
|
||||
if !sudo_ctdra::is_run_as_root() {
|
||||
anyhow::bail!("'{context}' requires root privileges (re-run with sudo).");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "smart-mount",
|
||||
version,
|
||||
about = "Dynamically mounts local/cloud drive pairs and switches between them automatically"
|
||||
)]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Commands {
|
||||
/// Manage drive pairs (add/edit/remove/list).
|
||||
Drive {
|
||||
#[command(subcommand)]
|
||||
action: Box<drive::DriveAction>,
|
||||
},
|
||||
/// Mount configured drive pairs.
|
||||
Mount {
|
||||
/// Only mount this pair (ID or name).
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
/// Mount all configured pairs.
|
||||
#[arg(long)]
|
||||
all: bool,
|
||||
},
|
||||
/// Unmount configured drive pairs.
|
||||
Unmount {
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
all: bool,
|
||||
},
|
||||
/// Shows the current mount status.
|
||||
Status {
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
/// Output as JSON instead of text - for scripts.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// A single reconcile pass (local/cloud switching) - meant for systemd timers/cron.
|
||||
Watch,
|
||||
/// Sets up (or removes) the cron fallback for systems without (or not using) systemd -
|
||||
/// the systemd service itself is installed/removed automatically by the .deb/.rpm/
|
||||
/// .pkg.tar.zst package, not via this CLI.
|
||||
Service {
|
||||
#[command(subcommand)]
|
||||
action: service::ServiceAction,
|
||||
},
|
||||
/// Checks prerequisites (binaries, scheduler).
|
||||
Doctor {
|
||||
/// Output as JSON instead of text - for scripts.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Prints a shell completion script, e.g.:
|
||||
/// `smart-mount completions bash > /etc/bash_completion.d/smart-mount`.
|
||||
Completions { shell: Shell },
|
||||
}
|
||||
|
||||
/// Führt das per `Cli` geparste Subcommand aus.
|
||||
pub async fn dispatch(cli: Cli) -> anyhow::Result<()> {
|
||||
match cli.command {
|
||||
Commands::Drive { action } => drive::run(*action).await,
|
||||
Commands::Mount { name, all } => mount_cmd::run_mount(name, all).await,
|
||||
Commands::Unmount { name, all } => mount_cmd::run_unmount(name, all).await,
|
||||
Commands::Status { name, json } => status::run(name, json).await,
|
||||
Commands::Watch => watch::run().await,
|
||||
Commands::Service { action } => service::run(action),
|
||||
Commands::Doctor { json } => doctor::run(json).await,
|
||||
Commands::Completions { shell } => {
|
||||
clap_complete::generate(
|
||||
shell,
|
||||
&mut Cli::command(),
|
||||
"smart-mount",
|
||||
&mut std::io::stdout(),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! `smart-mount mount` / `smart-mount unmount`.
|
||||
|
||||
use smart_mount::config::{self, DrivePair};
|
||||
use smart_mount::db::credentials::CredentialStore;
|
||||
use smart_mount::reconcile;
|
||||
|
||||
fn select_pairs(
|
||||
cfg: &smart_mount::config::AppConfig,
|
||||
name: Option<&str>,
|
||||
all: bool,
|
||||
) -> anyhow::Result<Vec<DrivePair>> {
|
||||
if let Some(name) = name {
|
||||
return Ok(vec![config::pairs::find_pair(cfg, name)?]);
|
||||
}
|
||||
if !all {
|
||||
anyhow::bail!("Please specify '--name <id>' or '--all'.");
|
||||
}
|
||||
Ok(cfg.pairs.clone())
|
||||
}
|
||||
|
||||
pub async fn run_mount(name: Option<String>, all: bool) -> anyhow::Result<()> {
|
||||
crate::cli::require_root("mount")?;
|
||||
let cfg = config::pairs::load()?;
|
||||
let pairs = select_pairs(&cfg, name.as_deref(), all)?;
|
||||
if pairs.is_empty() {
|
||||
println!("No matching drive pairs found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let creds = CredentialStore::open().await?;
|
||||
for pair in &pairs {
|
||||
let outcome = reconcile::reconcile_pair(pair, &cfg.settings, &creds).await;
|
||||
print_outcome(&outcome);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run_unmount(name: Option<String>, all: bool) -> anyhow::Result<()> {
|
||||
crate::cli::require_root("unmount")?;
|
||||
let cfg = config::pairs::load()?;
|
||||
let pairs = select_pairs(&cfg, name.as_deref(), all)?;
|
||||
if pairs.is_empty() {
|
||||
println!("No matching drive pairs found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for pair in &pairs {
|
||||
match reconcile::unmount_pair(pair, &cfg.settings).await {
|
||||
Ok(_) => println!("{} ({}): unmounted", pair.name, pair.id),
|
||||
Err(e) => println!("{} ({}): ERROR: {e}", pair.name, pair.id),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn print_outcome(outcome: &reconcile::ReconcileOutcome) {
|
||||
use reconcile::Action;
|
||||
let action_str = match &outcome.action {
|
||||
Action::NoOp => "no change".to_string(),
|
||||
Action::MountedLocal => "mounted local".to_string(),
|
||||
Action::MountedCloud => "mounted cloud".to_string(),
|
||||
Action::SwitchedToLocal => "switched to local".to_string(),
|
||||
Action::SwitchedToCloud => "switched to cloud".to_string(),
|
||||
Action::Failed(e) => format!("ERROR: {e}"),
|
||||
};
|
||||
println!("{} ({}): {action_str}", outcome.pair_name, outcome.pair_id);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! `smart-mount service crontab [--remove]`.
|
||||
|
||||
use clap::Subcommand;
|
||||
use smart_mount::config;
|
||||
use smart_mount::systemd;
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum ServiceAction {
|
||||
/// Sets up (or, with '--remove', tears down) periodic execution via
|
||||
/// '/etc/cron.d/smart-mount' - a manual fallback for systems that don't use the packaged
|
||||
/// systemd service (see 'packaging/systemd/' in the source tree). Requires root, same as
|
||||
/// 'mount'/'watch' - mounting always runs as root.
|
||||
Crontab {
|
||||
/// Removes a previously installed cron entry instead of installing one.
|
||||
#[arg(long)]
|
||||
remove: bool,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn run(action: ServiceAction) -> anyhow::Result<()> {
|
||||
crate::cli::require_root("service crontab")?;
|
||||
match action {
|
||||
ServiceAction::Crontab { remove: false } => install(),
|
||||
ServiceAction::Crontab { remove: true } => uninstall(),
|
||||
}
|
||||
}
|
||||
|
||||
fn install() -> anyhow::Result<()> {
|
||||
let cfg = config::pairs::load()?;
|
||||
let interval = cfg.settings.watch_interval_secs;
|
||||
|
||||
match systemd::install_cron(interval)? {
|
||||
systemd::CronInstallOutcome::SystemFile(path) => {
|
||||
println!("Cron entry written: {}", path.display());
|
||||
}
|
||||
systemd::CronInstallOutcome::Unavailable => {
|
||||
println!(
|
||||
"No cron mechanism found ('/etc/cron.d' is missing) - here are the lines for manual entry:"
|
||||
);
|
||||
print!("{}", systemd::crontab_equivalent(interval));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn uninstall() -> anyhow::Result<()> {
|
||||
match systemd::uninstall_cron()? {
|
||||
systemd::CronUninstallOutcome::Removed => println!("Cron entry removed."),
|
||||
systemd::CronUninstallOutcome::NotPresent => {
|
||||
println!("Nothing to remove - no cron entry was installed.")
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! `smart-mount status`.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use smart_mount::config;
|
||||
use smart_mount::db::credentials::Side;
|
||||
use smart_mount::mount::target;
|
||||
use smart_mount::network;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PairStatus {
|
||||
id: String,
|
||||
name: String,
|
||||
mount_point: String,
|
||||
active: Option<&'static str>,
|
||||
local_source: String,
|
||||
local_reachable: bool,
|
||||
cloud_source: String,
|
||||
cloud_reachable: bool,
|
||||
}
|
||||
|
||||
pub async fn run(name: Option<String>, json: bool) -> anyhow::Result<()> {
|
||||
let cfg = config::pairs::load()?;
|
||||
let pairs = match &name {
|
||||
Some(name) => vec![config::pairs::find_pair(&cfg, name)?],
|
||||
None => cfg.pairs.clone(),
|
||||
};
|
||||
|
||||
if pairs.is_empty() {
|
||||
if json {
|
||||
println!("[]");
|
||||
} else {
|
||||
println!("No drive pairs configured.");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let statuses: Vec<PairStatus> = pairs
|
||||
.iter()
|
||||
.map(|pair| {
|
||||
let active = match target::active_side(pair) {
|
||||
Some(Side::Local) => Some("local"),
|
||||
Some(Side::Cloud) => Some("cloud"),
|
||||
None => None,
|
||||
};
|
||||
// Die lokale Adresse (bei MAC-Adressierung ein `mac2ip`-Subprozessaufruf) wird nur
|
||||
// einmal pro Paar aufgelöst und für Quelle UND Erreichbarkeitsprüfung
|
||||
// wiederverwendet, statt sie für beide Zwecke unabhängig voneinander ein zweites
|
||||
// Mal aufzulösen.
|
||||
let resolved_local_ip =
|
||||
smart_mount::network::address::resolve_ip(&pair.local.address, &cfg.settings);
|
||||
let local_source = resolved_local_ip
|
||||
.as_ref()
|
||||
.map(|ip| target::format_local_source(&pair.local, *ip))
|
||||
.unwrap_or_else(|_| "unresolved".to_string());
|
||||
let local_reachable = resolved_local_ip
|
||||
.as_ref()
|
||||
.map(|ip| network::is_reachable(&ip.to_string()))
|
||||
.unwrap_or(false);
|
||||
|
||||
PairStatus {
|
||||
id: pair.id.clone(),
|
||||
name: pair.name.clone(),
|
||||
mount_point: pair.mount_point.display().to_string(),
|
||||
active,
|
||||
local_source,
|
||||
local_reachable,
|
||||
cloud_source: target::cloud_source(&pair.cloud),
|
||||
cloud_reachable: network::is_reachable(&pair.cloud.host_or_url),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&statuses)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for status in &statuses {
|
||||
println!("{} ({})", status.name, status.id);
|
||||
println!(" Symlink: {}", status.mount_point);
|
||||
println!(" Mounted: {}", status.active.unwrap_or("not mounted"));
|
||||
println!(
|
||||
" Local: {} [{}]",
|
||||
status.local_source,
|
||||
reachable_str(status.local_reachable)
|
||||
);
|
||||
println!(
|
||||
" Cloud: {} [{}]",
|
||||
status.cloud_source,
|
||||
reachable_str(status.cloud_reachable)
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reachable_str(reachable: bool) -> &'static str {
|
||||
if reachable {
|
||||
"reachable"
|
||||
} else {
|
||||
"unreachable"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! `smart-mount watch` - ein einzelner Reconcile-Durchlauf, gedacht für systemd-Timer/Cron.
|
||||
|
||||
use smart_mount::config;
|
||||
use smart_mount::db::credentials::CredentialStore;
|
||||
use smart_mount::reconcile::{self, Action};
|
||||
|
||||
use crate::cli::mount_cmd::print_outcome;
|
||||
|
||||
pub async fn run() -> anyhow::Result<()> {
|
||||
crate::cli::require_root("watch")?;
|
||||
let cfg = config::pairs::load()?;
|
||||
let creds = CredentialStore::open().await?;
|
||||
let outcomes = reconcile::watch_once(&cfg, &creds).await;
|
||||
|
||||
let mut had_failure = false;
|
||||
for outcome in &outcomes {
|
||||
print_outcome(outcome);
|
||||
if matches!(outcome.action, Action::Failed(_)) {
|
||||
had_failure = true;
|
||||
}
|
||||
}
|
||||
|
||||
if had_failure {
|
||||
anyhow::bail!("at least one drive pair could not be reconciled");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
-178
@@ -1,178 +0,0 @@
|
||||
//! Modul für die Konfigurationsverwaltung der Anwendung.
|
||||
//!
|
||||
//! Dieses Modul stellt die Funktionalität zum Laden, Speichern und Verwalten
|
||||
//! der Anwendungskonfiguration bereit. Die Konfiguration wird in einer Datei gespeichert
|
||||
//! und beim Programmstart automatisch geladen.
|
||||
|
||||
use crate::program;
|
||||
use crate::sudo::is_run_as_root;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Hauptkonfigurationsstruktur der Anwendung.
|
||||
///
|
||||
/// Enthält alle Konfigurationseinstellungen, aufgeteilt in verschiedene Bereiche.
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct AppConfig {
|
||||
/// Allgemeine Einstellungen
|
||||
pub general: General,
|
||||
/// Lokale Einbindungseinstellungen
|
||||
pub local: Local,
|
||||
/// Remote-Einbindungseinstellungen
|
||||
pub remote: Remote,
|
||||
/// Lokaler Zwischenspeicher
|
||||
pub storage: Option<Storage>,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
general: General::default(),
|
||||
local: Local::default(),
|
||||
remote: Remote::default(),
|
||||
storage: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Allgemeine Konfigurationseinstellungen.
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct General {
|
||||
/// Log-Level für die Anwendung
|
||||
pub log_level: String,
|
||||
/// Pfad zum Einhängepunkt
|
||||
pub mount_point: String,
|
||||
}
|
||||
|
||||
impl Default for General {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
log_level: "info".to_string(),
|
||||
mount_point: get_default_mount_path(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Konfiguration für lokale Einbindungen.
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct Local {
|
||||
/// Typ der lokalen Einbindung (z.B. "nfs")
|
||||
pub mount_type: String,
|
||||
/// Pfad zur lokalen Einbindung
|
||||
pub mount_path: String,
|
||||
/// MAC-Adresse des lokalen Geräts
|
||||
pub device_mac: String,
|
||||
}
|
||||
|
||||
impl Default for Local {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mount_type: "nfs".to_string(),
|
||||
mount_path: "".to_string(),
|
||||
device_mac: "".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Konfiguration für Remote-Einbindungen.
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct Remote {
|
||||
/// Typ der Remote-Einbindung (z.B. "webdav")
|
||||
pub mount_type: String,
|
||||
/// Pfad zur Remote-Einbindung
|
||||
pub mount_path: String,
|
||||
/// Benutzername für Remote-Zugriff
|
||||
pub username: String,
|
||||
/// Passwort für Remote-Zugriff
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl Default for Remote {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mount_type: "webdav".to_string(),
|
||||
mount_path: "".to_string(),
|
||||
username: "".to_string(),
|
||||
password: "".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zwischenspeicher für das Programm.
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct Storage {
|
||||
pub device_ip: String,
|
||||
}
|
||||
|
||||
impl Default for Storage {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
device_ip: "".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ermittelt den Standard-Einhängepunkt basierend auf dem Betriebssystem.
|
||||
fn get_default_mount_path() -> String {
|
||||
#[cfg(not(any(target_os = "windows")))]
|
||||
{
|
||||
"/media/smart_mount".to_string()
|
||||
}
|
||||
#[cfg(any(target_os = "windows"))]
|
||||
{
|
||||
let drives = ('C'..='Z').collect::<Vec<char>>();
|
||||
for drive in drives {
|
||||
let drive_string = format!("{}:", drive);
|
||||
if !std::path::Path::new(&drive_string).exists() {
|
||||
return format!("{}:", drive);
|
||||
}
|
||||
}
|
||||
"H".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
static CONFIG_NAME: &str = "config";
|
||||
static CONFIG: OnceLock<AppConfig> = OnceLock::new();
|
||||
|
||||
/// Gibt die aktuelle Konfiguration zurück.
|
||||
///
|
||||
/// Lädt die Konfiguration beim ersten Aufruf und speichert sie zwischen.
|
||||
/// Nachfolgende Aufrufe geben die gespeicherte Konfiguration zurück.
|
||||
pub fn get_config() -> &'static AppConfig {
|
||||
CONFIG.get_or_init(|| {
|
||||
load_config()
|
||||
})
|
||||
}
|
||||
|
||||
/// Modifiziert die aktuelle Konfiguration mit der übergebenen Mutator-Funktion.
|
||||
///
|
||||
/// Die Funktion lädt die Konfiguration neu von der Festplatte, wendet die Mutator-Funktion an
|
||||
/// und speichert die geänderte Konfiguration anschließend wieder.
|
||||
pub fn modify_config<F>(mutator: F)
|
||||
where
|
||||
F: FnOnce(&mut AppConfig),
|
||||
{
|
||||
// Immer frisch von Disk laden, damit Änderungen konsistent sind
|
||||
let mut cfg = load_config();
|
||||
mutator(&mut cfg);
|
||||
save_config(cfg);
|
||||
}
|
||||
|
||||
/// Lädt die Konfiguration aus der Konfigurationsdatei.
|
||||
fn load_config() -> AppConfig {
|
||||
#[cfg(target_os = "linux")]
|
||||
if is_run_as_root() {
|
||||
return confy::load_path(format!("/etc/{}/{}.toml", program::program_name(), CONFIG_NAME)).unwrap_or_default();
|
||||
}
|
||||
confy::load(&*program::program_name(), CONFIG_NAME).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Speichert die übergebene Konfiguration in der Konfigurationsdatei.
|
||||
fn save_config(config: AppConfig) {
|
||||
#[cfg(target_os = "linux")]
|
||||
if is_run_as_root() {
|
||||
confy::store_path(format!("/etc/{}/{}.toml", program::program_name(), CONFIG_NAME), config).unwrap();
|
||||
return;
|
||||
}
|
||||
confy::store(&*program::program_name(), CONFIG_NAME, config).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! Konfigurationsverwaltung: Schema + CRUD auf Laufwerkspaaren, aufbauend auf `config-ctdra`.
|
||||
|
||||
pub mod pairs;
|
||||
pub mod schema;
|
||||
|
||||
pub use schema::{
|
||||
AppConfig, CloudSide, DrivePair, GlobalSettings, LocalAddress, LocalSide, MountKind,
|
||||
};
|
||||
|
||||
/// Initialisiert `config-ctdra`: Dateiname und - unabhängig von den Rechten des aufrufenden
|
||||
/// Prozesses - immer der System-Pfad (`/etc/<programmname>/config.toml`). Es gibt nur noch
|
||||
/// eine einzige, systemweite Konfiguration (smart-mount läuft ausschließlich als root/
|
||||
/// System-Dienst, siehe [`crate::systemd`]) statt einer je nach Aufrufer wechselnden
|
||||
/// Root-/Nutzerpfad-Auflösung - `drive add`/`edit`/`remove` verlangen deshalb explizit Root
|
||||
/// (siehe `cli::require_root`), auch wenn sie nicht direkt mounten.
|
||||
///
|
||||
/// Muss vor dem ersten `load`/`store`/`get_config`-Aufruf laufen (globaler, prozessweiter
|
||||
/// Zustand).
|
||||
pub fn init() {
|
||||
config_ctdra::set_config_name("config");
|
||||
let program = config_ctdra::get_program_name();
|
||||
config_ctdra::set_custom_dir(std::path::PathBuf::from("/etc").join(program));
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! CRUD-Operationen auf [`AppConfig::pairs`], atomar über `config_ctdra::modify`.
|
||||
|
||||
use crate::config::schema::{AppConfig, DrivePair};
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Lädt die aktuelle Konfiguration frisch von der Platte.
|
||||
pub fn load() -> Result<AppConfig> {
|
||||
Ok(config_ctdra::load::<AppConfig>()?)
|
||||
}
|
||||
|
||||
/// Fügt ein neues Laufwerkspaar hinzu (load-mutate-store in einem atomaren Schritt).
|
||||
pub fn add_pair(pair: DrivePair) -> Result<AppConfig> {
|
||||
Ok(config_ctdra::modify::<AppConfig, _>(|cfg| {
|
||||
cfg.pairs.push(pair.clone());
|
||||
})?)
|
||||
}
|
||||
|
||||
/// Ersetzt ein bestehendes Laufwerkspaar (Vergleich über `id`).
|
||||
pub fn update_pair(pair: DrivePair) -> Result<AppConfig> {
|
||||
let id = pair.id.clone();
|
||||
let updated = config_ctdra::modify::<AppConfig, _>(move |cfg| {
|
||||
if let Some(existing) = cfg.pairs.iter_mut().find(|p| p.id == pair.id) {
|
||||
*existing = pair.clone();
|
||||
}
|
||||
})?;
|
||||
if !updated.pairs.iter().any(|p| p.id == id) {
|
||||
return Err(Error::PairNotFound(id));
|
||||
}
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// Entfernt ein Laufwerkspaar per ID.
|
||||
pub fn remove_pair(id: &str) -> Result<AppConfig> {
|
||||
// Die "vorher"-Länge wird INNERHALB desselben `modify`-Aufrufs (auf der bereits frisch
|
||||
// geladenen `cfg`) ermittelt statt über einen separaten, vorgelagerten `load()`-Aufruf:
|
||||
// zwischen zwei getrennten Aufrufen könnte ein nebenläufiger Schreiber die Paarliste
|
||||
// ändern, was hier sonst zu einem falschen `PairNotFound` trotz erfolgreichem Entfernen
|
||||
// (oder umgekehrt) führen könnte.
|
||||
let removed = std::cell::Cell::new(false);
|
||||
let updated = config_ctdra::modify::<AppConfig, _>(|cfg| {
|
||||
let before_len = cfg.pairs.len();
|
||||
cfg.pairs.retain(|p| p.id != id);
|
||||
removed.set(cfg.pairs.len() != before_len);
|
||||
})?;
|
||||
if !removed.get() {
|
||||
return Err(Error::PairNotFound(id.to_string()));
|
||||
}
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// Sucht ein Laufwerkspaar per ID oder (fallback) exaktem Namen.
|
||||
pub fn find_pair(cfg: &AppConfig, id_or_name: &str) -> Result<DrivePair> {
|
||||
cfg.pairs
|
||||
.iter()
|
||||
.find(|p| p.id == id_or_name || p.name == id_or_name)
|
||||
.cloned()
|
||||
.ok_or_else(|| Error::PairNotFound(id_or_name.to_string()))
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Konfigurationsschema: globale Einstellungen + Liste konfigurierter Laufwerkspaare.
|
||||
//!
|
||||
//! Passwörter sind hier bewusst NICHT enthalten - sie leben verschlüsselt in der
|
||||
//! Turso-Datenbank (siehe [`crate::db::credentials`]), niemals im Klartext in der TOML-Datei.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// `/run/media` ist die auf diesem System bereits übliche Konvention für eingebundene
|
||||
/// Wechseldatenträger/Netzlaufwerke (z. B. udisks2/GNOME) - tmpfs-hinterlegt, wird also bei
|
||||
/// jedem Boot ohnehin leer neu angelegt, passend dazu, dass Mountpoints selbst nie
|
||||
/// persistieren müssen. Jedes Paar bekommt direkt darunter sein eigenes, nach seinem Namen
|
||||
/// benanntes Unterverzeichnis (`/run/media/<Name>`, siehe `cli::drive::default_mount_point`) -
|
||||
/// kein zusätzlicher `smart-mount`-Zwischenordner, damit der Ordnername beim Durchsuchen von
|
||||
/// `/run/media` sofort erkennbar ist. Die Trennung nach Linux-Nutzer passiert bei Bedarf über
|
||||
/// die `uid=`/`gid=`-Mount-Optionen (siehe [`crate::mount::target::build_target`]), nicht über
|
||||
/// unterschiedliche Mountpoint-Namensräume - smart-mount läuft ausschließlich als
|
||||
/// root/System-Dienst (siehe [`crate::systemd`]).
|
||||
fn default_mount_base_dir() -> PathBuf {
|
||||
PathBuf::from("/run/media")
|
||||
}
|
||||
|
||||
fn default_log_level() -> String {
|
||||
"info".to_string()
|
||||
}
|
||||
|
||||
fn default_watch_interval_secs() -> u64 {
|
||||
120
|
||||
}
|
||||
|
||||
fn default_mac2ip_binary() -> String {
|
||||
"mac2ip".to_string()
|
||||
}
|
||||
|
||||
/// Wurzel-Konfigurationsstruktur, gespeichert via `config-ctdra` immer unter
|
||||
/// `/etc/smart-mount/config.toml` (siehe [`crate::config::init`]).
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
pub struct AppConfig {
|
||||
#[serde(default)]
|
||||
pub settings: GlobalSettings,
|
||||
#[serde(default)]
|
||||
pub pairs: Vec<DrivePair>,
|
||||
}
|
||||
|
||||
/// Globale, paarunabhängige Einstellungen.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct GlobalSettings {
|
||||
#[serde(default = "default_mount_base_dir")]
|
||||
pub mount_base_dir: PathBuf,
|
||||
#[serde(default = "default_log_level")]
|
||||
pub log_level: String,
|
||||
/// Periode, mit der `smart-mount watch` über den Cron-Fallback (`smart-mount service
|
||||
/// crontab`, siehe [`crate::systemd`]) ausgeführt werden soll. Der paketierte systemd-Timer
|
||||
/// hat ein fest eingebautes Intervall (siehe `packaging/systemd/smart-mount-watch.timer`)
|
||||
/// und liest dieses Feld nicht.
|
||||
#[serde(default = "default_watch_interval_secs")]
|
||||
pub watch_interval_secs: u64,
|
||||
/// Name/Pfad des `mac2ip`-Binaries (per PATH auflösbar, oder absoluter Pfad).
|
||||
#[serde(default = "default_mac2ip_binary")]
|
||||
pub mac2ip_binary: String,
|
||||
}
|
||||
|
||||
impl Default for GlobalSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mount_base_dir: default_mount_base_dir(),
|
||||
log_level: default_log_level(),
|
||||
watch_interval_secs: default_watch_interval_secs(),
|
||||
mac2ip_binary: default_mac2ip_binary(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein konfiguriertes Laufwerkspaar: eine lokale (LAN) und eine Cloud-Seite, die dasselbe
|
||||
/// logische Laufwerk repräsentieren. Nur eine Seite ist zu einem Zeitpunkt an `mount_point`
|
||||
/// eingebunden - siehe [`crate::reconcile`].
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct DrivePair {
|
||||
/// Stabile ID (uuid-v4), Schlüssel für DB-Zugangsdaten sowie das Mount-Unterverzeichnis.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// Optional: der Linux-Benutzername, der vollen Zugriff (uid/gid) auf dieses Paar
|
||||
/// bekommen soll (siehe [`crate::mount::target::build_target`]). Ohne `owner_user` gehört
|
||||
/// der Mount root.
|
||||
#[serde(default)]
|
||||
pub owner_user: Option<String>,
|
||||
pub mount_point: PathBuf,
|
||||
pub local: LocalSide,
|
||||
pub cloud: CloudSide,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Die lokale (LAN-)Seite eines Laufwerkspaars.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct LocalSide {
|
||||
pub kind: MountKind,
|
||||
pub address: LocalAddress,
|
||||
/// Freigabename/Exportpfad (SMB-Share, NFS-Export, WebDAV-Pfadsegment).
|
||||
pub share: String,
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub extra_options: Vec<String>,
|
||||
}
|
||||
|
||||
/// Die Cloud-Seite eines Laufwerkspaars.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct CloudSide {
|
||||
pub kind: MountKind,
|
||||
/// Volle URL (WebDAV) bzw. Server-Adresse (SMB/NFS).
|
||||
pub host_or_url: String,
|
||||
pub share: String,
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub extra_options: Vec<String>,
|
||||
}
|
||||
|
||||
/// Unterstützte Mount-Verfahren. Jede Variante wird dynamisch auf ein
|
||||
/// [`crate::mount::MountBackend`] dispatcht - siehe dort für die "nur bei Bedarf laden"-Logik.
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash, clap::ValueEnum)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[value(rename_all = "lowercase")]
|
||||
pub enum MountKind {
|
||||
WebDav,
|
||||
Smb,
|
||||
Nfs,
|
||||
}
|
||||
|
||||
impl MountKind {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
MountKind::WebDav => "webdav",
|
||||
MountKind::Smb => "smb",
|
||||
MountKind::Nfs => "nfs",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adressierung der lokalen Seite: entweder direkt per IP oder per MAC-Adresse, die zur
|
||||
/// Laufzeit über `mac2ip` aufgelöst wird (siehe [`crate::network::mac2ip`]).
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LocalAddress {
|
||||
Ip(Ipv4Addr),
|
||||
Mac(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn app_config_round_trips_through_toml() {
|
||||
let cfg = AppConfig {
|
||||
settings: GlobalSettings::default(),
|
||||
pairs: vec![DrivePair {
|
||||
id: "pair-1".into(),
|
||||
name: "NAS".into(),
|
||||
enabled: true,
|
||||
owner_user: Some("dragon".into()),
|
||||
mount_point: PathBuf::from("/home/dragon/smart-mount/pair-1"),
|
||||
local: LocalSide {
|
||||
kind: MountKind::WebDav,
|
||||
address: LocalAddress::Mac("aa:bb:cc:dd:ee:ff".into()),
|
||||
share: "/dav".into(),
|
||||
username: Some("nasuser".into()),
|
||||
extra_options: vec![],
|
||||
},
|
||||
cloud: CloudSide {
|
||||
kind: MountKind::WebDav,
|
||||
host_or_url: "https://cloud.example.com/remote.php/dav/files/dragon".into(),
|
||||
share: "/".into(),
|
||||
username: Some("dragon".into()),
|
||||
extra_options: vec![],
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
let toml_str = toml::to_string_pretty(&cfg).expect("serialize");
|
||||
let round_tripped: AppConfig = toml::from_str(&toml_str).expect("deserialize");
|
||||
|
||||
assert_eq!(round_tripped.pairs.len(), 1);
|
||||
assert_eq!(round_tripped.pairs[0].id, "pair-1");
|
||||
match &round_tripped.pairs[0].local.address {
|
||||
LocalAddress::Mac(mac) => assert_eq!(mac, "aa:bb:cc:dd:ee:ff"),
|
||||
LocalAddress::Ip(_) => panic!("expected Mac variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_mount_base_dir_uses_run_media() {
|
||||
assert_eq!(default_mount_base_dir(), PathBuf::from("/run/media"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Master-Schlüssel-Auflösung für die Zugangsdaten-Verschlüsselung.
|
||||
//!
|
||||
//! Immer die Schlüsseldatei: SmartMount läuft ausschließlich als root/System-Dienst (siehe
|
||||
//! [`crate::systemd`]), für den es kein Nutzer-Keyring (GNOME Keyring/KWallet über
|
||||
//! secret-service) gibt.
|
||||
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
const KEY_FILE_NAME: &str = "master.key";
|
||||
const KEY_LEN: usize = 32;
|
||||
|
||||
/// Ermittelt (und erzeugt bei Bedarf) den 256-Bit-Master-Schlüssel für die
|
||||
/// Zugangsdaten-Verschlüsselung.
|
||||
pub fn resolve_master_key() -> Result<[u8; 32]> {
|
||||
file_key::load_or_create(&key_file_path())
|
||||
}
|
||||
|
||||
fn key_file_path() -> PathBuf {
|
||||
let config_path = config_ctdra::get_config_path();
|
||||
config_path
|
||||
.parent()
|
||||
.map(|dir| dir.join(KEY_FILE_NAME))
|
||||
.unwrap_or_else(|| PathBuf::from(KEY_FILE_NAME))
|
||||
}
|
||||
|
||||
mod file_key {
|
||||
use super::*;
|
||||
|
||||
pub fn load_or_create(path: &Path) -> Result<[u8; 32]> {
|
||||
if path.exists() {
|
||||
return read(path);
|
||||
}
|
||||
create(path)
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> Result<[u8; 32]> {
|
||||
let mut file = File::open(path).map_err(|e| Error::io(path, e))?;
|
||||
let mut buf = Vec::with_capacity(KEY_LEN);
|
||||
file.read_to_end(&mut buf).map_err(|e| Error::io(path, e))?;
|
||||
if buf.len() != KEY_LEN {
|
||||
return Err(Error::Crypto(format!(
|
||||
"key file '{}' has unexpected length ({} instead of {KEY_LEN} bytes)",
|
||||
path.display(),
|
||||
buf.len()
|
||||
)));
|
||||
}
|
||||
let mut key = [0u8; KEY_LEN];
|
||||
key.copy_from_slice(&buf);
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn create(path: &Path) -> Result<[u8; 32]> {
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
|
||||
}
|
||||
|
||||
let mut key = [0u8; KEY_LEN];
|
||||
fill_random(&mut key)?;
|
||||
|
||||
#[cfg(unix)]
|
||||
let mut opts = OpenOptions::new();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let mut opts = OpenOptions::new();
|
||||
|
||||
match opts.write(true).create_new(true).open(path) {
|
||||
Ok(mut file) => {
|
||||
file.write_all(&key).map_err(|e| Error::io(path, e))?;
|
||||
Ok(key)
|
||||
}
|
||||
// Another process won the race and created the file first; use its key instead of
|
||||
// silently generating our own (which would desynchronize the two processes' keys).
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => read(path),
|
||||
Err(e) => Err(Error::io(path, e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn fill_random(buf: &mut [u8]) -> Result<()> {
|
||||
::getrandom::fill(buf)
|
||||
.map_err(|e| Error::Crypto(format!("Random number generator failed: {e}")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Verschlüsselung von Zugangsdaten vor der Ablage in der Turso-Datenbank.
|
||||
//!
|
||||
//! Turso hat aktuell keine produktionsreife eingebaute Verschlüsselung (nur ein
|
||||
//! experimentelles, unauditiertes Feature) - Passwörter werden daher hier selbst mit
|
||||
//! AES-256-GCM verschlüsselt, bevor sie als Ciphertext-BLOB in die DB geschrieben werden.
|
||||
|
||||
pub mod key;
|
||||
|
||||
use aes_gcm::aead::{Aead, KeyInit};
|
||||
// `aead::Nonce<A>` ist über den AEAD-Algorithmus-Typ parametrisiert (löst intern
|
||||
// `<A as AeadCore>::NonceSize` auf) - anders als `aes_gcm::Nonce<NonceSize>`, das direkt
|
||||
// über die Array-Länge parametrisiert ist. Für `Nonce::<Aes256Gcm>` brauchen wir Ersteres.
|
||||
use aes_gcm::aead::Nonce;
|
||||
use aes_gcm::{Aes256Gcm, Key};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// AES-GCM-Nonce-Länge in Byte (96 Bit, Standard für AES-256-GCM).
|
||||
pub const NONCE_LEN: usize = 12;
|
||||
|
||||
/// Verschlüsselt `plaintext` mit dem gegebenen 256-Bit-Schlüssel.
|
||||
///
|
||||
/// Gibt `(ciphertext, nonce)` zurück - beide werden zusammen mit dem Datensatz gespeichert;
|
||||
/// der Schlüssel selbst wird niemals in der Datenbank abgelegt.
|
||||
///
|
||||
/// Erzeugt die Nonce bewusst über eine eigene, direkte `getrandom`-Abhängigkeit statt über
|
||||
/// `aes_gcm::aead::rand_core::OsRng` - Letzteres ist seit aes-gcm 0.11 nicht mehr ohne
|
||||
/// Weiteres erreichbar (rand_core 0.9s `OsRng` steckt hinter dem `os_rng`-Feature, das über
|
||||
/// aes-gcms Re-Export-Kette nicht automatisch aktiviert wird). `getrandom::fill` ist
|
||||
/// unabhängig davon stabil und genau für diesen Zweck gedacht.
|
||||
pub fn encrypt(plaintext: &[u8], key: &[u8; 32]) -> Result<(Vec<u8>, Vec<u8>)> {
|
||||
let cipher = Aes256Gcm::new(&Key::<Aes256Gcm>::from(*key));
|
||||
|
||||
let mut nonce_bytes = [0u8; NONCE_LEN];
|
||||
getrandom::fill(&mut nonce_bytes)
|
||||
.map_err(|e| Error::Crypto(format!("Random number generator failed: {e}")))?;
|
||||
let nonce: Nonce<Aes256Gcm> = nonce_bytes.into();
|
||||
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, plaintext)
|
||||
.map_err(|e| Error::Crypto(format!("Encryption failed: {e}")))?;
|
||||
Ok((ciphertext, nonce.to_vec()))
|
||||
}
|
||||
|
||||
/// Entschlüsselt einen zuvor mit [`encrypt`] erzeugten Ciphertext.
|
||||
pub fn decrypt(ciphertext: &[u8], nonce: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
if nonce.len() != NONCE_LEN {
|
||||
return Err(Error::Crypto(format!(
|
||||
"invalid nonce length: expected {NONCE_LEN}, got {}",
|
||||
nonce.len()
|
||||
)));
|
||||
}
|
||||
let cipher = Aes256Gcm::new(&Key::<Aes256Gcm>::from(*key));
|
||||
let nonce: Nonce<Aes256Gcm> = Nonce::<Aes256Gcm>::try_from(nonce)
|
||||
.map_err(|_| Error::Crypto("invalid nonce".to_string()))?;
|
||||
cipher
|
||||
.decrypt(&nonce, ciphertext)
|
||||
.map_err(|e| Error::Crypto(format!("Decryption failed: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_round_trip() {
|
||||
let key = [7u8; 32];
|
||||
let plaintext = b"correct horse battery staple";
|
||||
|
||||
let (ciphertext, nonce) = encrypt(plaintext, &key).expect("encrypt");
|
||||
assert_ne!(ciphertext, plaintext);
|
||||
|
||||
let decrypted = decrypt(&ciphertext, &nonce, &key).expect("decrypt");
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_fails_with_wrong_key() {
|
||||
let key = [1u8; 32];
|
||||
let other_key = [2u8; 32];
|
||||
let (ciphertext, nonce) = encrypt(b"secret", &key).expect("encrypt");
|
||||
assert!(decrypt(&ciphertext, &nonce, &other_key).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Verschlüsselte Zugangsdaten-CRUD auf der `credentials`-Tabelle.
|
||||
|
||||
use crate::crypto;
|
||||
use crate::crypto::key::resolve_master_key;
|
||||
use crate::error::Result;
|
||||
|
||||
fn now_unix() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Welche Seite eines Laufwerkspaars die Zugangsdaten betreffen.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Side {
|
||||
Local,
|
||||
Cloud,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Side::Local => "local",
|
||||
Side::Cloud => "cloud",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Entschlüsselte Zugangsdaten für eine Seite eines Laufwerkspaars.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Credential {
|
||||
pub username: Option<String>,
|
||||
pub domain: Option<String>,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Dünner Wrapper um die `credentials`-Tabelle; ver-/entschlüsselt transparent mit dem
|
||||
/// per [`resolve_master_key`] ermittelten Schlüssel.
|
||||
pub struct CredentialStore {
|
||||
conn: turso::Connection,
|
||||
}
|
||||
|
||||
impl CredentialStore {
|
||||
/// Öffnet die Datenbank und initialisiert das Schema bei Bedarf.
|
||||
pub async fn open() -> Result<Self> {
|
||||
Ok(Self {
|
||||
conn: crate::db::open().await?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Speichert (oder ersetzt) die Zugangsdaten für `pair_id`/`side`.
|
||||
pub async fn put(
|
||||
&self,
|
||||
pair_id: &str,
|
||||
side: Side,
|
||||
username: Option<&str>,
|
||||
domain: Option<&str>,
|
||||
password: &str,
|
||||
) -> Result<()> {
|
||||
let key = resolve_master_key()?;
|
||||
let (ciphertext, nonce) = crypto::encrypt(password.as_bytes(), &key)?;
|
||||
let now = now_unix();
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT INTO credentials (pair_id, side, username, domain, ciphertext, nonce, updated_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
|
||||
ON CONFLICT(pair_id, side) DO UPDATE SET \
|
||||
username = excluded.username, domain = excluded.domain, \
|
||||
ciphertext = excluded.ciphertext, nonce = excluded.nonce, updated_at = excluded.updated_at",
|
||||
(
|
||||
pair_id.to_string(),
|
||||
side.as_str().to_string(),
|
||||
username.map(str::to_string),
|
||||
domain.map(str::to_string),
|
||||
ciphertext,
|
||||
nonce,
|
||||
now,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Liest und entschlüsselt die Zugangsdaten für `pair_id`/`side`, falls vorhanden.
|
||||
pub async fn get(&self, pair_id: &str, side: Side) -> Result<Option<Credential>> {
|
||||
let mut rows = self
|
||||
.conn
|
||||
.query(
|
||||
"SELECT username, domain, ciphertext, nonce FROM credentials WHERE pair_id = ?1 AND side = ?2",
|
||||
(pair_id.to_string(), side.as_str().to_string()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some(row) = rows.next().await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let username: Option<String> = row.get(0)?;
|
||||
let domain: Option<String> = row.get(1)?;
|
||||
let ciphertext: Vec<u8> = row.get(2)?;
|
||||
let nonce: Vec<u8> = row.get(3)?;
|
||||
|
||||
let key = resolve_master_key()?;
|
||||
let plaintext = crypto::decrypt(&ciphertext, &nonce, &key)?;
|
||||
let password = String::from_utf8(plaintext).map_err(|e| {
|
||||
crate::error::Error::Crypto(format!("password is not valid UTF-8: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(Some(Credential {
|
||||
username,
|
||||
domain,
|
||||
password,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Löscht Zugangsdaten. `side = None` löscht beide Seiten (z. B. beim Entfernen eines Paars).
|
||||
pub async fn delete(&self, pair_id: &str, side: Option<Side>) -> Result<()> {
|
||||
match side {
|
||||
Some(side) => {
|
||||
self.conn
|
||||
.execute(
|
||||
"DELETE FROM credentials WHERE pair_id = ?1 AND side = ?2",
|
||||
(pair_id.to_string(), side.as_str().to_string()),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
None => {
|
||||
self.conn
|
||||
.execute(
|
||||
"DELETE FROM credentials WHERE pair_id = ?1",
|
||||
(pair_id.to_string(),),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Lokale Turso-Datenbank für verschlüsselt gespeicherte Zugangsdaten.
|
||||
|
||||
pub mod credentials;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
const DB_FILE_NAME: &str = "smart-mount.db";
|
||||
|
||||
/// Pfad zur Datenbankdatei: dasselbe Verzeichnis wie die Konfigurationsdatei, folgt also
|
||||
/// automatisch derselben Root-/User-Auflösung wie `config-ctdra`.
|
||||
pub fn resolve_db_path() -> PathBuf {
|
||||
let config_path = config_ctdra::get_config_path();
|
||||
config_path
|
||||
.parent()
|
||||
.map(|dir| dir.join(DB_FILE_NAME))
|
||||
.unwrap_or_else(|| PathBuf::from(DB_FILE_NAME))
|
||||
}
|
||||
|
||||
/// Öffnet (und initialisiert bei Bedarf) die lokale Datenbank am aufgelösten Pfad.
|
||||
pub async fn open() -> Result<turso::Connection> {
|
||||
let path = resolve_db_path();
|
||||
if let Some(dir) = path.parent() {
|
||||
tokio::fs::create_dir_all(dir)
|
||||
.await
|
||||
.map_err(|e| Error::io(dir, e))?;
|
||||
}
|
||||
let db = turso::Builder::new_local(path.to_string_lossy().as_ref())
|
||||
.build()
|
||||
.await?;
|
||||
let conn = db.connect()?;
|
||||
init_schema(&conn).await?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
async fn init_schema(conn: &turso::Connection) -> Result<()> {
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS credentials (\
|
||||
pair_id TEXT NOT NULL, \
|
||||
side TEXT NOT NULL CHECK(side IN ('local','cloud')), \
|
||||
username TEXT, \
|
||||
domain TEXT, \
|
||||
ciphertext BLOB NOT NULL, \
|
||||
nonce BLOB NOT NULL, \
|
||||
updated_at INTEGER NOT NULL, \
|
||||
PRIMARY KEY (pair_id, side)\
|
||||
)",
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
//! Diagnose-Checks für `smart-mount doctor` - prüft die im Laufe der Entwicklung
|
||||
//! angesammelten Voraussetzungen (Binaries, Scheduler) gebündelt an einer Stelle, statt sie
|
||||
//! einzeln erst beim Mount-Fehlschlag zu entdecken.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::config::{AppConfig, LocalAddress, MountKind};
|
||||
use crate::mount;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CheckStatus {
|
||||
Ok,
|
||||
Warn,
|
||||
Fail,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CheckResult {
|
||||
pub label: String,
|
||||
pub status: CheckStatus,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
fn ok(label: impl Into<String>, detail: impl Into<String>) -> CheckResult {
|
||||
CheckResult {
|
||||
label: label.into(),
|
||||
status: CheckStatus::Ok,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
fn warn(label: impl Into<String>, detail: impl Into<String>) -> CheckResult {
|
||||
CheckResult {
|
||||
label: label.into(),
|
||||
status: CheckStatus::Warn,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
fn fail(label: impl Into<String>, detail: impl Into<String>) -> CheckResult {
|
||||
CheckResult {
|
||||
label: label.into(),
|
||||
status: CheckStatus::Fail,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Führt alle Checks gegen die aktuelle Konfiguration aus.
|
||||
pub fn run_checks(cfg: &AppConfig) -> Vec<CheckResult> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
results.push(check_timeout_binary());
|
||||
results.push(check_scheduler());
|
||||
results.push(check_mount_base_dir(&cfg.settings.mount_base_dir));
|
||||
|
||||
let used_kinds = used_mount_kinds(cfg);
|
||||
for kind in [MountKind::WebDav, MountKind::Smb, MountKind::Nfs] {
|
||||
if used_kinds.contains(&kind) {
|
||||
results.push(check_backend(kind));
|
||||
}
|
||||
}
|
||||
|
||||
if uses_mac_addressing(cfg) {
|
||||
results.push(check_binary(
|
||||
"mac2ip",
|
||||
&cfg.settings.mac2ip_binary,
|
||||
"install mac2ip (private tool, see README)",
|
||||
));
|
||||
results.push(check_binary("nmap", "nmap", "install package 'nmap'"));
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
fn used_mount_kinds(cfg: &AppConfig) -> HashSet<MountKind> {
|
||||
let mut set = HashSet::new();
|
||||
for pair in &cfg.pairs {
|
||||
set.insert(pair.local.kind);
|
||||
set.insert(pair.cloud.kind);
|
||||
}
|
||||
set
|
||||
}
|
||||
|
||||
fn uses_mac_addressing(cfg: &AppConfig) -> bool {
|
||||
cfg.pairs
|
||||
.iter()
|
||||
.any(|p| matches!(p.local.address, LocalAddress::Mac(_)))
|
||||
}
|
||||
|
||||
fn check_timeout_binary() -> CheckResult {
|
||||
if mount::binary_available("timeout") {
|
||||
ok(
|
||||
"timeout (coreutils)",
|
||||
"found - protects mount/umount against hanging indefinitely",
|
||||
)
|
||||
} else {
|
||||
fail(
|
||||
"timeout (coreutils)",
|
||||
"not found - mount/umount calls would block indefinitely, should be present on every Linux system",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn check_binary(name: &str, binary: &str, install_hint: &str) -> CheckResult {
|
||||
if mount::binary_available(binary) {
|
||||
ok(name, format!("'{binary}' found"))
|
||||
} else {
|
||||
fail(name, format!("'{binary}' not found - {install_hint}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn check_backend(kind: MountKind) -> CheckResult {
|
||||
let backend = mount::backend_for(kind);
|
||||
match backend.check_available() {
|
||||
Ok(()) => ok(format!("Backend: {}", backend.name()), "available"),
|
||||
Err(e) => fail(format!("Backend: {}", backend.name()), e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_scheduler() -> CheckResult {
|
||||
let systemd = crate::systemd::is_available();
|
||||
let cron_d = std::path::Path::new("/etc/cron.d").is_dir();
|
||||
let crontab = mount::binary_available("crontab");
|
||||
|
||||
if systemd {
|
||||
ok(
|
||||
"Scheduler",
|
||||
"systemd found - the packaged .deb/.rpm/.pkg.tar.zst installs/enables the smart-mount systemd service automatically",
|
||||
)
|
||||
} else if cron_d || crontab {
|
||||
warn(
|
||||
"Scheduler",
|
||||
"no systemd, but cron found - run 'smart-mount service crontab' to set up the periodic 'watch' call",
|
||||
)
|
||||
} else {
|
||||
fail(
|
||||
"Scheduler",
|
||||
"neither systemd nor cron found - periodic 'watch' must be set up manually",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn check_mount_base_dir(dir: &std::path::Path) -> CheckResult {
|
||||
if dir.is_dir() {
|
||||
ok("mount_base_dir", format!("'{}' exists", dir.display()))
|
||||
} else if dir.parent().is_some_and(std::path::Path::is_dir) {
|
||||
warn(
|
||||
"mount_base_dir",
|
||||
format!(
|
||||
"'{}' does not exist yet, will be created on first mount",
|
||||
dir.display()
|
||||
),
|
||||
)
|
||||
} else {
|
||||
fail(
|
||||
"mount_base_dir",
|
||||
format!(
|
||||
"'{}' does not exist and its parent directory is also missing",
|
||||
dir.display()
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{CloudSide, DrivePair, GlobalSettings, LocalSide};
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
fn sample_pair(owner_user: Option<&str>, mac: bool) -> DrivePair {
|
||||
DrivePair {
|
||||
id: "pair-1".into(),
|
||||
name: "Test".into(),
|
||||
enabled: true,
|
||||
owner_user: owner_user.map(str::to_string),
|
||||
mount_point: "/media/smart-mount/pair-1".into(),
|
||||
local: LocalSide {
|
||||
kind: MountKind::Nfs,
|
||||
address: if mac {
|
||||
LocalAddress::Mac("aa:bb:cc:dd:ee:ff".into())
|
||||
} else {
|
||||
LocalAddress::Ip(Ipv4Addr::new(192, 168, 1, 5))
|
||||
},
|
||||
share: "share".into(),
|
||||
username: None,
|
||||
extra_options: vec![],
|
||||
},
|
||||
cloud: CloudSide {
|
||||
kind: MountKind::WebDav,
|
||||
host_or_url: "https://cloud.example.com/dav".into(),
|
||||
share: "/".into(),
|
||||
username: None,
|
||||
extra_options: vec![],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn used_mount_kinds_collects_both_sides_across_pairs() {
|
||||
let cfg = AppConfig {
|
||||
settings: GlobalSettings::default(),
|
||||
pairs: vec![sample_pair(None, false)],
|
||||
};
|
||||
let kinds = used_mount_kinds(&cfg);
|
||||
assert!(kinds.contains(&MountKind::Nfs));
|
||||
assert!(kinds.contains(&MountKind::WebDav));
|
||||
assert!(!kinds.contains(&MountKind::Smb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_mac_addressing_detects_mac_pairs() {
|
||||
let with_mac = AppConfig {
|
||||
settings: GlobalSettings::default(),
|
||||
pairs: vec![sample_pair(None, true)],
|
||||
};
|
||||
let without_mac = AppConfig {
|
||||
settings: GlobalSettings::default(),
|
||||
pairs: vec![sample_pair(None, false)],
|
||||
};
|
||||
assert!(uses_mac_addressing(&with_mac));
|
||||
assert!(!uses_mac_addressing(&without_mac));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_config_still_runs_global_checks_without_panicking() {
|
||||
let cfg = AppConfig::default();
|
||||
let results = run_checks(&cfg);
|
||||
assert!(results.iter().any(|r| r.label.contains("timeout")));
|
||||
assert!(results.iter().any(|r| r.label == "Scheduler"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Zentraler Fehlertyp für alle Bibliotheksmodule.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Sammelfehler für alle `smart-mount`-Bibliotheksmodule.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(#[from] config_ctdra::ConfyError),
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
Db(#[from] turso::Error),
|
||||
|
||||
#[error("Encryption error: {0}")]
|
||||
Crypto(String),
|
||||
|
||||
#[error("Key storage error: {0}")]
|
||||
Keyring(String),
|
||||
|
||||
#[error("I/O error on '{path}': {source}")]
|
||||
Io {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
#[error("Drive pair '{0}' not found")]
|
||||
PairNotFound(String),
|
||||
|
||||
#[error("Mount backend '{backend}' not available: {reason}")]
|
||||
BackendUnavailable {
|
||||
backend: &'static str,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
#[error("Mount command failed ({context}): {stderr}")]
|
||||
MountFailed { context: String, stderr: String },
|
||||
|
||||
#[error("mac2ip resolution failed for MAC {mac}: {reason}")]
|
||||
Mac2Ip { mac: String, reason: String },
|
||||
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// Ergebnistyp-Alias für `smart-mount`-Bibliotheksfunktionen.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
impl Error {
|
||||
pub fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
|
||||
Self::Io {
|
||||
path: path.into(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
use crate::config::get_config;
|
||||
use crate::log::{log, LogLevel};
|
||||
use std::process::Command;
|
||||
|
||||
/// Speichert die Zugangsdaten für einen WebDAV-Mount-Punkt.
|
||||
///
|
||||
/// # Parameter
|
||||
/// * `mount_path` - Der Netzwerkpfad zum WebDAV-Server
|
||||
/// * `username` - Der Benutzername für den WebDAV-Zugriff
|
||||
/// * `password` - Das Passwort für den WebDAV-Zugriff
|
||||
pub fn save_credentials_webdav() {
|
||||
let config = get_config();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let output = Command::new("powershell")
|
||||
.args([
|
||||
"-Command",
|
||||
&format!("cmdkey /delete:{}", config.remote.mount_path)
|
||||
])
|
||||
.output()
|
||||
.expect("Failed to delete credentials");
|
||||
|
||||
let output = Command::new("powershell")
|
||||
.args([
|
||||
"-Command",
|
||||
&format!("cmdkey /add:{} /user:{} /pass:{}",
|
||||
config.remote.mount_path,
|
||||
config.remote.username,
|
||||
config.remote.password
|
||||
)
|
||||
])
|
||||
.output()
|
||||
.expect("Failed to store credentials");
|
||||
|
||||
if !output.status.success() {
|
||||
log(
|
||||
"credentials",
|
||||
"Could not store credentials in Windows Credential Manager",
|
||||
LogLevel::Error,
|
||||
);
|
||||
log(
|
||||
"credentials",
|
||||
&String::from_utf8_lossy(&output.stderr),
|
||||
LogLevel::Debug,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
if config.remote.username.is_empty() || config.remote.password.is_empty() {
|
||||
log(
|
||||
"credentials",
|
||||
"No credentials found in config",
|
||||
LogLevel::Error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let output = Command::new("touch")
|
||||
.arg("/etc/davfs2/secrets")
|
||||
.output()
|
||||
.expect("Failed to create secrets file");
|
||||
|
||||
if !output.status.success() {
|
||||
log(
|
||||
"credentials",
|
||||
"Could not create secrets file",
|
||||
LogLevel::Error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let output = Command::new("chmod")
|
||||
.arg("600")
|
||||
.arg("/etc/davfs2/secrets")
|
||||
.output()
|
||||
.expect("Failed to set secrets file permissions");
|
||||
|
||||
if !output.status.success() {
|
||||
log(
|
||||
"credentials",
|
||||
"Could not set secrets file permissions",
|
||||
LogLevel::Error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let credentials = format!("{} \"{}\" \"{}\"\n",
|
||||
config.general.mount_point,
|
||||
config.remote.username,
|
||||
config.remote.password
|
||||
);
|
||||
let mut content = std::fs::read_to_string("/etc/davfs2/secrets")
|
||||
.unwrap_or_default();
|
||||
|
||||
content = content.lines()
|
||||
.filter(|line| !line.starts_with(&config.general.mount_point))
|
||||
.collect::<Vec<&str>>()
|
||||
.join("\n");
|
||||
|
||||
if !content.is_empty() {
|
||||
content.push('\n');
|
||||
}
|
||||
content.push_str(&credentials);
|
||||
|
||||
std::fs::write("/etc/davfs2/secrets", content)
|
||||
.expect("Failed to write credentials");
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod mount;
|
||||
pub mod mounted;
|
||||
pub mod credentials;
|
||||
@@ -1,213 +0,0 @@
|
||||
use crate::filesystem::mounted::{/* is_mounted, */ FS_MOUNT_GUARD};
|
||||
use crate::log::{LogLevel, log};
|
||||
use std::process::Command;
|
||||
use std::sync::{Mutex, OnceLock, RwLock};
|
||||
use std::thread::sleep;
|
||||
use std::time::Duration;
|
||||
|
||||
static GUARD_MOUNT: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
static GUARD_UNMOUNT: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
|
||||
/// Führt die Einbindung eines Dateisystems durch.
|
||||
///
|
||||
/// # Parameter
|
||||
/// * `mount_point` - Der lokale Verzeichnispfad, an dem das Dateisystem eingebunden werden soll
|
||||
/// * `network_path` - Der Netzwerkpfad zum einzubindenden Dateisystem
|
||||
/// * `mount_type` - Der Mount-Typ, z.B. "nfs" oder "davfs2"
|
||||
pub fn mount(mount_point: &str, network_path: &str, mount_type: &str) {
|
||||
// 1) Globalen Write-Lock halten (blockiert parallele Statusabfragen)
|
||||
let _fs_write = FS_MOUNT_GUARD
|
||||
.get_or_init(|| RwLock::new(()))
|
||||
.write()
|
||||
.expect("mount rwlock poisoned");
|
||||
|
||||
// 2) Danach funktionsspezifischen Mutex sperren (konsistente Lock-Reihenfolge!)
|
||||
let m = GUARD_MOUNT.get_or_init(|| Mutex::new(()));
|
||||
let _lock = m.lock().expect("Mutex poisoned");
|
||||
|
||||
log(
|
||||
"mount",
|
||||
&*format!(
|
||||
"Mounting filesystem ({}) at \"{}\" as \"{}\"",
|
||||
network_path, mount_point, mount_type
|
||||
),
|
||||
LogLevel::Info,
|
||||
);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let output = Command::new("New-PSDrive")
|
||||
.args([
|
||||
"-Name",
|
||||
&mount_point[0..1], // First letter for drive letter
|
||||
"-PSProvider",
|
||||
"FileSystem",
|
||||
"-Root",
|
||||
network_path,
|
||||
"-Persist",
|
||||
"-Type",
|
||||
mount_type,
|
||||
])
|
||||
.output()
|
||||
.expect("Failed to execute mount command");
|
||||
|
||||
if !output.status.success() {
|
||||
log(
|
||||
"mount",
|
||||
&*format!(
|
||||
"Filesystem ({}) couldn't be mounted at \"{}\" as \"{}\"",
|
||||
network_path, mount_point, mount_type
|
||||
),
|
||||
LogLevel::Error,
|
||||
);
|
||||
log(
|
||||
"mount",
|
||||
&*String::from_utf8_lossy(&output.stderr),
|
||||
LogLevel::Debug,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// Verzeichnis vorbereiten
|
||||
let output = Command::new("mkdir")
|
||||
.arg("-p")
|
||||
.arg(mount_point)
|
||||
.output()
|
||||
.expect("Failed to create mount point directory");
|
||||
|
||||
if output.status.success() {
|
||||
log("mount", "Mountpoint created successfully!", LogLevel::Info);
|
||||
} else {
|
||||
log(
|
||||
"mount",
|
||||
&*String::from_utf8_lossy(&output.stderr),
|
||||
LogLevel::Debug,
|
||||
);
|
||||
}
|
||||
|
||||
let output = Command::new("chmod")
|
||||
.arg("777")
|
||||
.arg(mount_point)
|
||||
.output()
|
||||
.expect("Failed to set mount point permissions");
|
||||
|
||||
if output.status.success() {
|
||||
log(
|
||||
"mount",
|
||||
"Successfully set mount point permissions.",
|
||||
LogLevel::Info,
|
||||
);
|
||||
} else {
|
||||
log(
|
||||
"mount",
|
||||
&*String::from_utf8_lossy(&output.stderr),
|
||||
LogLevel::Debug,
|
||||
);
|
||||
}
|
||||
|
||||
let output = Command::new("mount")
|
||||
.arg("-t")
|
||||
.arg(mount_type)
|
||||
.arg(network_path)
|
||||
.arg(mount_point)
|
||||
.output()
|
||||
.expect("Failed to execute mount command");
|
||||
|
||||
sleep(Duration::from_secs(1));
|
||||
|
||||
if output.status.success() {
|
||||
log("mount", "Filesystem mounted successfully.", LogLevel::Info);
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
// Häufige „schon gemountet“/Busy-Indikatoren tolerant behandeln
|
||||
let already_or_busy = stderr.contains("already mounted")
|
||||
|| stderr.contains("is busy")
|
||||
|| stderr.contains("Device or resource busy")
|
||||
|| stderr.contains("EBUSY");
|
||||
|
||||
if already_or_busy {
|
||||
log(
|
||||
"mount",
|
||||
"Filesystem appears to be already mounted or mountpoint is busy. Treating as no-op.",
|
||||
LogLevel::Info,
|
||||
);
|
||||
log("mount", &*stderr, LogLevel::Debug);
|
||||
return;
|
||||
}
|
||||
|
||||
log(
|
||||
"mount",
|
||||
&*format!(
|
||||
"Filesystem ({}) couldn't be mounted at \"{}\" as \"{}\"",
|
||||
network_path, mount_point, mount_type
|
||||
),
|
||||
LogLevel::Error,
|
||||
);
|
||||
log("mount", &*stderr, LogLevel::Debug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hängt ein Dateisystem aus.
|
||||
///
|
||||
/// # Parameter
|
||||
/// * `mount_point` - Der lokale Verzeichnispfad, von dem das Dateisystem ausgehängt werden soll
|
||||
pub fn unmount(mount_point: &str) {
|
||||
// 1) Globalen Write-Lock halten (blockiert parallele Statusabfragen)
|
||||
let _fs_write = FS_MOUNT_GUARD
|
||||
.get_or_init(|| RwLock::new(()))
|
||||
.write()
|
||||
.expect("mount rwlock poisoned");
|
||||
|
||||
// 2) Danach funktionsspezifischen Mutex sperren (konsistente Lock-Reihenfolge!)
|
||||
let m = GUARD_UNMOUNT.get_or_init(|| Mutex::new(()));
|
||||
let _lock = m.lock().expect("Mutex poisoned");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let output = Command::new("Remove-PSDrive")
|
||||
.args([
|
||||
"-Name",
|
||||
&mount_point[0..1], // First letter for drive letter
|
||||
"-Force",
|
||||
])
|
||||
.output()
|
||||
.expect("Failed to execute unmount command");
|
||||
|
||||
if !output.status.success() {
|
||||
log(
|
||||
"unmount",
|
||||
&*format!("Filesystem couldn't be unmounted from \"{}\"", mount_point),
|
||||
LogLevel::Error,
|
||||
);
|
||||
log(
|
||||
"unmount",
|
||||
&*String::from_utf8_lossy(&output.stderr),
|
||||
LogLevel::Debug,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let output = Command::new("umount")
|
||||
.arg(mount_point)
|
||||
.output()
|
||||
.expect("Failed to execute unmount command");
|
||||
|
||||
if !output.status.success() {
|
||||
log(
|
||||
"unmount",
|
||||
&*format!("Filesystem couldn't be unmounted from \"{}\"", mount_point),
|
||||
LogLevel::Error,
|
||||
);
|
||||
log(
|
||||
"unmount",
|
||||
&*String::from_utf8_lossy(&output.stderr),
|
||||
LogLevel::Debug,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::Path;
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
|
||||
// Globaler RW-Lock für Mount-Operationen und Statusabfragen
|
||||
pub static FS_MOUNT_GUARD: OnceLock<RwLock<()>> = OnceLock::new();
|
||||
|
||||
fn guard() -> &'static RwLock<()> {
|
||||
FS_MOUNT_GUARD.get_or_init(|| RwLock::new(()))
|
||||
}
|
||||
|
||||
/// Überprüft, ob ein Dateisystem am angegebenen Mount-Point mit dem spezifizierten Mount-Typ eingebunden ist.
|
||||
///
|
||||
/// # Parameter
|
||||
/// * `mount_point` - Der Pfad, an dem das Dateisystem eingebunden sein soll
|
||||
/// * `mount_type` - Der erwartete Mount-Typ des Dateisystems (z.B. nfs, smb, davfs2)
|
||||
///
|
||||
/// # Rückgabewert
|
||||
/// * `true` wenn das Dateisystem mit dem angegebenen Typ eingebunden ist
|
||||
/// * `false` wenn das Dateisystem nicht oder mit einem anderen Typ eingebunden ist
|
||||
pub fn is_mounted_as(mount_point: &str, mount_type: &str) -> bool {
|
||||
// Während Statusabfragen nur Read-Lock halten
|
||||
let _read_guard = guard().read().expect("mount rwlock poisoned");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::process::Command;
|
||||
let drive_letter = &mount_point[0..1];
|
||||
let output = Command::new("powershell")
|
||||
.args([
|
||||
"-Command",
|
||||
&format!(
|
||||
"(Get-PSDrive -Name {} -PSProvider 'FileSystem').Description",
|
||||
drive_letter
|
||||
),
|
||||
])
|
||||
.output()
|
||||
.expect("Failed to execute get-psdrive command");
|
||||
|
||||
if !output.status.success() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let drive_type = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
drive_type == mount_type
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let norm_mp = normalize_mount_point(mount_point);
|
||||
|
||||
for entry in read_proc_mounts() {
|
||||
let (mp, fstype, _opts, _src) = entry;
|
||||
if mp == norm_mp {
|
||||
if type_matches(mount_type, &fstype, &_opts) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Überprüft, ob ein Dateisystem am angegebenen Mount-Point eingebunden ist.
|
||||
///
|
||||
/// # Parameter
|
||||
/// * `mount_point` - Der Pfad, an dem das Dateisystem eingebunden sein soll
|
||||
///
|
||||
/// # Rückgabewert
|
||||
/// * `true` wenn ein Dateisystem am angegebenen Pfad eingebunden ist
|
||||
/// * `false` wenn kein Dateisystem eingebunden ist
|
||||
pub fn is_mounted(mount_point: &str) -> bool {
|
||||
// Während Statusabfragen nur Read-Lock halten
|
||||
let _read_guard = guard().read().expect("mount rwlock poisoned");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::process::Command;
|
||||
let drive_letter = &mount_point[0..1];
|
||||
let output = Command::new("powershell")
|
||||
.args([
|
||||
"-Command",
|
||||
&format!(
|
||||
"(Get-PSDrive -Name {} -PSProvider 'FileSystem')",
|
||||
drive_letter
|
||||
),
|
||||
])
|
||||
.output()
|
||||
.expect("Failed to execute get-psdrive command");
|
||||
|
||||
output.status.success()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let norm_mp = normalize_mount_point(mount_point);
|
||||
read_proc_mounts()
|
||||
.into_iter()
|
||||
.any(|(mp, _, _, _)| mp == norm_mp)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn read_proc_mounts() -> Vec<(String, String, String, String)> {
|
||||
// Liefert Tupel: (mount_point, fstype, options, source)
|
||||
let file = match File::open("/proc/mounts") {
|
||||
Ok(f) => f,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
let reader = BufReader::new(file);
|
||||
let mut result = Vec::new();
|
||||
|
||||
for line in reader.lines().flatten() {
|
||||
// Format /proc/mounts:
|
||||
// fs_spec fs_file fs_vfstype fs_mntops fs_freq fs_passno
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() < 6 {
|
||||
continue;
|
||||
}
|
||||
let fs_spec = parts[0].to_string();
|
||||
let fs_file = parts[1].to_string();
|
||||
let fs_vfstype = parts[2].to_string();
|
||||
let fs_mntops = parts[3].to_string();
|
||||
|
||||
result.push((fs_file, fs_vfstype, fs_mntops, fs_spec));
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn normalize_mount_point(p: &str) -> String {
|
||||
// Entfernt redundante Slashes am Ende (außer bei "/") und canonicalized soweit möglich.
|
||||
if p == "/" {
|
||||
return "/".to_string();
|
||||
}
|
||||
let trimmed = p.trim_end_matches('/');
|
||||
// Versuche, realpath zu bilden, falle sonst auf trimmed zurück
|
||||
let path = Path::new(trimmed);
|
||||
path.canonicalize()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| trimmed.to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn type_matches(expected: &str, actual_fstype: &str, options: &str) -> bool {
|
||||
// Normalisiere erwartete Typfamilien
|
||||
match expected {
|
||||
// NFS kann als nfs oder nfs4 erscheinen
|
||||
"nfs" | "nfs4" => actual_fstype == "nfs" || actual_fstype == "nfs4",
|
||||
|
||||
// davfs/webdav erscheint häufig als fuse.davfs (oder fuse mit helper=davfs)
|
||||
"webdav" | "davfs" | "davfs2" => {
|
||||
actual_fstype == "fuse.davfs"
|
||||
|| actual_fstype == "davfs"
|
||||
|| (actual_fstype == "fuse" && options.contains("helper=davfs"))
|
||||
}
|
||||
|
||||
// CIFS/Samba Alias
|
||||
"cifs" | "smb" | "smb3" => actual_fstype == "cifs" || actual_fstype == "smb3",
|
||||
|
||||
// Fallback: exakter Vergleich
|
||||
other => other == actual_fstype,
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
//! smart-mount: bindet lokale/Cloud-Laufwerkspaare (WebDAV/SMB/NFS) dynamisch ein und
|
||||
//! schaltet automatisch zwischen LAN und Cloud um.
|
||||
|
||||
pub mod config;
|
||||
pub mod crypto;
|
||||
pub mod db;
|
||||
pub mod doctor;
|
||||
pub mod error;
|
||||
pub mod mount;
|
||||
pub mod network;
|
||||
pub mod reconcile;
|
||||
pub mod systemd;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
-241
@@ -1,241 +0,0 @@
|
||||
//! Einfaches, threadsicheres Logging-Modul.
|
||||
//!
|
||||
//! Merkmale:
|
||||
//! - Ausgabe in Terminal (stdout/stderr) und zusätzlich in eine Logdatei im temporären Verzeichnis.
|
||||
//! - Nachrichtenvorlage: `[DD.MM.YYYY HH:MM:SS.mmm][LEVEL][TAG]: <Text>` mit Emoji-Präfix (🛑/⚠️/ℹ️/🚧).
|
||||
//! - Der aktuell verwendete Schweregradfilter ist statisch (`LOG_LEVEL`) und wird zur Laufzeit nicht geändert.
|
||||
//! - Terminal-Erkennung und Dateihandle werden lazily initialisiert und zwischengespeichert (`OnceLock`).
|
||||
//!
|
||||
//! Hinweise:
|
||||
//! - Bei einem leeren `tag` wird ein leerer Tag-Abschnitt erzeugt.
|
||||
//! - Die Logdatei wird unterhalb eines prozessspezifischen Ordners im System-Temp-Verzeichnis abgelegt und nach Datum
|
||||
//! benannt (z. B. `log-2025-08-18.log`). Es wird im Append-Modus geschrieben.
|
||||
//!
|
||||
//! Beispiel (ohne Ausführung in Doctests):
|
||||
//! ```rust,no_run
|
||||
//! use crate::log::{log, LogLevel};
|
||||
//!
|
||||
//! log("startup", "Dienst wird initialisiert …", LogLevel::Info);
|
||||
//! log("db", "Verbindung unterbrochen!", LogLevel::Warn);
|
||||
//! log("core", "Unerwarteter Fehler", LogLevel::Error);
|
||||
//! ```
|
||||
|
||||
use std::env;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::fs::{create_dir_all, File, OpenOptions};
|
||||
use std::io::{stdout, IsTerminal, Write};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use crate::config;
|
||||
use crate::program;
|
||||
use time::{macros::format_description, OffsetDateTime};
|
||||
|
||||
/// Schweregrade für Logeinträge in aufsteigender Detailtiefe.
|
||||
///
|
||||
/// Die Reihenfolge bestimmt den Filter: Nur Einträge mit `log_level <= LOG_LEVEL` werden ausgegeben.
|
||||
///
|
||||
/// Anzeige (Display):
|
||||
/// - `Error` -> `ERROR`
|
||||
/// - `Warn` -> `WARN`
|
||||
/// - `Info` -> `INFO`
|
||||
/// - `Debug` -> `DEBUG`
|
||||
#[derive(PartialEq, Eq, PartialOrd, Ord, Copy, Clone)]
|
||||
pub enum LogLevel {
|
||||
/// Kritische Fehler, gehen zusätzlich auf `stderr`.
|
||||
Error = 1,
|
||||
/// Wichtige Warnungen über potenzielle Probleme.
|
||||
Warn = 2,
|
||||
/// Allgemeine Betriebsinformationen.
|
||||
Info = 3,
|
||||
/// Ausführliche Diagnoseausgaben für die Entwicklung.
|
||||
Debug = 4,
|
||||
}
|
||||
|
||||
impl Display for LogLevel {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
LogLevel::Error => write!(f, "ERROR"),
|
||||
LogLevel::Warn => write!(f, "WARN"),
|
||||
LogLevel::Info => write!(f, "INFO"),
|
||||
LogLevel::Debug => write!(f, "DEBUG"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for LogLevel {
|
||||
type Error = LogLevel;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self> {
|
||||
match value.to_lowercase().as_str() {
|
||||
"error" => Ok(LogLevel::Error),
|
||||
"warn" => Ok(LogLevel::Warn),
|
||||
"info" => Ok(LogLevel::Info),
|
||||
"debug" => Ok(LogLevel::Debug),
|
||||
_ => Err(LogLevel::Info)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zwischenspeicher für die einmalig ermittelte Terminal-Fähigkeit von `stdout`.
|
||||
static IS_TERMINAL: OnceLock<bool> = OnceLock::new();
|
||||
|
||||
/// Globaler Schweregradfilter für Ausgabe.
|
||||
static LOG_LEVEL: OnceLock<LogLevel> = OnceLock::new();
|
||||
|
||||
/// Lazy-initialisiertes Handle zur Logdatei; kann `None` sein, falls das Öffnen fehlschlug.
|
||||
static LOG_FILE: OnceLock<Mutex<Option<File>>> = OnceLock::new();
|
||||
|
||||
/// Protokolliert eine Nachricht abhängig vom angegebenen Schweregrad.
|
||||
///
|
||||
/// Verhalten:
|
||||
/// - Wenn `log_level` größer als der globale Filter ist, wird nichts ausgegeben.
|
||||
/// - Bei Terminalausgabe gehen `Error`-Meldungen nach `stderr`, alle anderen nach `stdout`.
|
||||
/// - Zusätzlich wird in eine tägliche Logdatei im Temp-Verzeichnis geschrieben (wenn erfolgreich geöffnet).
|
||||
///
|
||||
/// Parameter:
|
||||
/// - `tag`: Kurzer Kontext (z. B. Modulname).
|
||||
/// - `message`: Die eigentliche Nachricht (eine Zeile).
|
||||
/// - `log_level`: Schweregrad der Nachricht.
|
||||
///
|
||||
/// Thread-Sicherheit:
|
||||
/// - Dateischreibzugriffe sind über `Mutex` serialisiert.
|
||||
///
|
||||
/// Beispiel:
|
||||
/// ```rust,no_run
|
||||
/// # use crate::log::{log, LogLevel};
|
||||
/// log("http", "Server gestartet auf Port 8080", LogLevel::Info);
|
||||
/// ```
|
||||
pub fn log(tag: &str, message: &str, log_level: LogLevel) {
|
||||
if log_level <= get_log_level() {
|
||||
let message: String = format_message(tag, message, &log_level);
|
||||
|
||||
if is_terminal() {
|
||||
if log_level == LogLevel::Error {
|
||||
eprintln!("{}", message)
|
||||
} else {
|
||||
println!("{}", message)
|
||||
}
|
||||
}
|
||||
|
||||
let file_lock = get_or_init_log_file();
|
||||
if let Ok(mut guard) = file_lock.lock() {
|
||||
if let Some(f) = guard.as_mut() {
|
||||
let _ = writeln!(f, "{}", message);
|
||||
let _ = f.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Liefert das einmalig initialisierte Log-Level für die Filterung.
|
||||
///
|
||||
/// Ermittelt das konfigurierte Log-Level aus der Config-Datei.
|
||||
/// Wenn ungültig oder nicht vorhanden, wird LogLevel::Info als Fallback verwendet.
|
||||
///
|
||||
/// Rückgabe:
|
||||
/// - `LogLevel`: Das zu verwendende Log-Level als enum-Wert.
|
||||
fn get_log_level() -> LogLevel {
|
||||
*LOG_LEVEL.get_or_init(|| {
|
||||
let log_level = &config::get_config().general.log_level;
|
||||
LogLevel::try_from(log_level.to_string()).unwrap_or(LogLevel::Info)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Ermittelt einmalig, ob `stdout` ein Terminal ist, und cached das Ergebnis.
|
||||
///
|
||||
/// Rückgabe:
|
||||
/// - `true`, wenn `stdout` ein TTY/Terminal ist.
|
||||
/// - `false` andernfalls.
|
||||
fn is_terminal() -> bool {
|
||||
*IS_TERMINAL.get_or_init(|| stdout().is_terminal())
|
||||
}
|
||||
|
||||
/// Formatiert eine Lognachricht mit Zeitstempel, Level, Tag und Emoji-Präfix.
|
||||
///
|
||||
/// Format:
|
||||
/// - Datum/Zeit lokal (Fallback: UTC) im Format `DD.MM.YYYY HH:MM:SS.mmm`.
|
||||
/// - Level als Text (`ERROR`, `WARN`, `INFO`, `DEBUG`).
|
||||
/// - Tag in eckigen Klammern; bei leerem Tag entsteht ein leeres `[]`.
|
||||
/// - Emoji-Präfix pro Level: 🛑/⚠️/ℹ️/🚧.
|
||||
///
|
||||
/// Beispielausgabe:
|
||||
/// - `ℹ️[18.08.2025 14:23:45.012][INFO][init]: Fertig`
|
||||
///
|
||||
/// Hinweis:
|
||||
/// - Diese Funktion formatiert nur; sie führt keinen I/O aus.
|
||||
fn format_message(tag: &str, message: &str, log_level: &LogLevel) -> String {
|
||||
let mut prefix: String = String::new();
|
||||
|
||||
let now_local: OffsetDateTime =
|
||||
OffsetDateTime::now_local().unwrap_or(OffsetDateTime::now_utc());
|
||||
let fmt =
|
||||
format_description!("[day].[month].[year] [hour]:[minute]:[second].[subsecond digits:3]");
|
||||
|
||||
if tag != "" {
|
||||
prefix = format!("[{}]", tag);
|
||||
}
|
||||
|
||||
let message = format!(
|
||||
"[{}][{}][{}]: {}",
|
||||
now_local.format(fmt).unwrap(),
|
||||
log_level.to_string(),
|
||||
prefix,
|
||||
message
|
||||
);
|
||||
|
||||
match log_level {
|
||||
&LogLevel::Error => format!("{}{}", "🛑", message),
|
||||
&LogLevel::Warn => format!("{}{}", "⚠️", message),
|
||||
&LogLevel::Info => format!("{}{}", "ℹ️", message),
|
||||
&LogLevel::Debug => format!("{}{}", "🚧", message),
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialisiert das Logdatei-Handle beim ersten Aufruf und liefert eine Referenz darauf.
|
||||
///
|
||||
/// Rückgabe:
|
||||
/// - `&'static Mutex<Option<File>>`: Das Mutex schützt den optionalen Dateihandler.
|
||||
/// `None` bedeutet, dass das Öffnen fehlgeschlagen ist (z. B. fehlende Rechte).
|
||||
fn get_or_init_log_file() -> &'static Mutex<Option<File>> {
|
||||
LOG_FILE.get_or_init(|| {
|
||||
let file = open_log_file().ok();
|
||||
Mutex::new(file)
|
||||
})
|
||||
}
|
||||
|
||||
/// Öffnet (und erstellt bei Bedarf) die tagesbasierte Logdatei im Temp-Verzeichnis.
|
||||
///
|
||||
/// Pfadaufbau:
|
||||
/// - Basis: `std::env::temp_dir()`
|
||||
/// - Unterordner: `<programmname>-<konstante-uuid>`
|
||||
/// - Datei: `log-YYYY-MM-DD.log` im Append-Modus
|
||||
///
|
||||
/// Rückgabe:
|
||||
/// - `Ok(File)`, wenn der Ordner erstellt/gefunden und die Datei geöffnet/angelegt werden konnte.
|
||||
/// - `Err(std::io::Error)`, wenn ein I/O-Fehler auftrat.
|
||||
///
|
||||
/// Fehler:
|
||||
/// - Gibt I/O-Fehler unverändert weiter (z. B. beim Erstellen des Ordners oder Öffnen der Datei).
|
||||
fn open_log_file() -> std::io::Result<File> {
|
||||
let program = program::program_name();
|
||||
let rand = "13692bbf-a93b-43e9-9cc6-f05f94a8cfb6";
|
||||
|
||||
let mut dir = env::temp_dir();
|
||||
dir.push(format!("{}-{}", program, rand));
|
||||
create_dir_all(&dir)?;
|
||||
|
||||
let today_fmt = format_description!("[year]-[month]-[day]");
|
||||
let date_str = OffsetDateTime::now_local()
|
||||
.unwrap_or(OffsetDateTime::now_utc())
|
||||
.format(today_fmt)
|
||||
.unwrap_or_else(|_| "0000-00-00".to_string());
|
||||
|
||||
let file_name = format!("log-{}.log", date_str);
|
||||
dir.push(file_name);
|
||||
|
||||
OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(dir)
|
||||
}
|
||||
+23
-324
@@ -1,335 +1,34 @@
|
||||
use crate::config::{Storage, get_config, modify_config};
|
||||
use crate::filesystem::credentials::save_credentials_webdav;
|
||||
use crate::filesystem::mount::{mount, unmount};
|
||||
use crate::filesystem::mounted::{is_mounted, is_mounted_as};
|
||||
use crate::log::LogLevel;
|
||||
use crate::log::log;
|
||||
use crate::network::network_interface::{get_active_network_interface, get_interface_ip_address};
|
||||
use crate::network::utils::{
|
||||
get_ip_from_mac, get_mac_from_ip, get_network_address, is_reachable, wake_on_land,
|
||||
};
|
||||
use crate::sudo::{is_run_as_root, run_as_root};
|
||||
use std::process::exit;
|
||||
use std::thread;
|
||||
use std::thread::{JoinHandle, sleep};
|
||||
use std::time::Duration;
|
||||
mod cli;
|
||||
|
||||
mod config;
|
||||
mod filesystem;
|
||||
mod log;
|
||||
mod network;
|
||||
mod program;
|
||||
mod sudo;
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() {
|
||||
log(
|
||||
"main",
|
||||
"========== PROGRAM START ==========",
|
||||
LogLevel::Info,
|
||||
);
|
||||
use clap::Parser;
|
||||
|
||||
if !is_run_as_root() {
|
||||
log(
|
||||
"main",
|
||||
"Program is not run as root. Trying to run as root...",
|
||||
LogLevel::Warn,
|
||||
);
|
||||
run_as_root();
|
||||
}
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
smart_mount::config::init();
|
||||
|
||||
let network_interface: String;
|
||||
let log_level = match smart_mount::config::pairs::load() {
|
||||
Ok(cfg) => cfg.settings.log_level,
|
||||
Err(_) => "info".to_string(),
|
||||
};
|
||||
logger_ctdra::set_log_level(parse_log_level(&log_level));
|
||||
|
||||
let mut count: i32 = 0;
|
||||
loop {
|
||||
let interface_str: String = get_active_network_interface().unwrap().trim().to_string();
|
||||
|
||||
if !interface_str.is_empty() {
|
||||
network_interface = interface_str;
|
||||
break;
|
||||
} else if count >= 10 {
|
||||
log(
|
||||
"main",
|
||||
"Couldn't find active network card, exiting.",
|
||||
LogLevel::Error,
|
||||
);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
log(
|
||||
"main",
|
||||
"No active network card found, waiting 1 second.",
|
||||
LogLevel::Warn,
|
||||
);
|
||||
count = count + 1;
|
||||
sleep(Duration::from_secs(1));
|
||||
}
|
||||
log(
|
||||
"main",
|
||||
&*format!("Active network interface found: {}", network_interface),
|
||||
LogLevel::Info,
|
||||
);
|
||||
|
||||
let interface_address = get_interface_ip_address(network_interface.as_str())
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string();
|
||||
log(
|
||||
"main",
|
||||
&*format!("Interface address: {}", interface_address),
|
||||
LogLevel::Info,
|
||||
);
|
||||
|
||||
let network_address = get_network_address(interface_address.as_str())
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string();
|
||||
log(
|
||||
"main",
|
||||
&*format!("Network address: {}", network_address),
|
||||
LogLevel::Info,
|
||||
);
|
||||
|
||||
count = 0;
|
||||
loop {
|
||||
if is_reachable(network_address.as_str()) {
|
||||
break;
|
||||
} else if count >= 10 {
|
||||
log(
|
||||
"main",
|
||||
"Couldn't reach network address, exiting.",
|
||||
LogLevel::Error,
|
||||
);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
log(
|
||||
"main",
|
||||
"Network address not reachable, waiting 1 second.",
|
||||
LogLevel::Warn,
|
||||
);
|
||||
count = count + 1;
|
||||
sleep(Duration::from_secs(1));
|
||||
}
|
||||
log("main", "Network address is reachable.", LogLevel::Info);
|
||||
|
||||
let handle_local: JoinHandle<()> = thread::spawn(move || mount_local(network_address));
|
||||
let handle_remote: JoinHandle<()> = thread::spawn(mount_remote);
|
||||
|
||||
handle_local.join().unwrap();
|
||||
handle_remote.join().unwrap();
|
||||
|
||||
log("main", "========== PROGRAM END ==========", LogLevel::Info);
|
||||
}
|
||||
|
||||
fn mount_local(network_address: String) {
|
||||
log(
|
||||
"main",
|
||||
"Trying to mount filesystem locally...",
|
||||
LogLevel::Info,
|
||||
);
|
||||
|
||||
let mount_point: &str = get_config().general.mount_point.as_str();
|
||||
|
||||
let mac_address: &str = get_config().local.device_mac.as_str();
|
||||
let mount_type: &str = get_config().local.mount_type.as_str();
|
||||
|
||||
let mut device_address: Option<String> = None;
|
||||
|
||||
if get_config().storage.is_some() {
|
||||
device_address = Some(get_config().storage.clone().unwrap().device_ip);
|
||||
|
||||
if is_reachable(&device_address.clone().unwrap()) {
|
||||
log(
|
||||
"main",
|
||||
format!(
|
||||
"Searching mac for device address {}.",
|
||||
device_address.clone().unwrap()
|
||||
)
|
||||
.as_str(),
|
||||
LogLevel::Info,
|
||||
);
|
||||
let mac_of_ip =
|
||||
get_mac_from_ip(device_address.clone().unwrap().as_str()).unwrap_or("".to_string());
|
||||
log(
|
||||
"main",
|
||||
format!(
|
||||
"Found mac {} for device address {}.",
|
||||
mac_of_ip,
|
||||
device_address.clone().unwrap()
|
||||
)
|
||||
.as_str(),
|
||||
LogLevel::Info,
|
||||
);
|
||||
|
||||
if mac_of_ip == mac_address {
|
||||
log(
|
||||
"main",
|
||||
format!(
|
||||
"Found device mac {} on saved ip {}.",
|
||||
mac_address,
|
||||
device_address.clone().unwrap()
|
||||
)
|
||||
.as_str(),
|
||||
LogLevel::Info,
|
||||
);
|
||||
} else {
|
||||
log(
|
||||
"main",
|
||||
format!(
|
||||
"Device mac {} is not the same as {} of ip {}.",
|
||||
mac_address,
|
||||
mac_of_ip,
|
||||
device_address.clone().unwrap()
|
||||
)
|
||||
.as_str(),
|
||||
LogLevel::Warn,
|
||||
);
|
||||
device_address = None;
|
||||
}
|
||||
} else {
|
||||
log(
|
||||
"main",
|
||||
format!(
|
||||
"Device address {} is not reachable.",
|
||||
device_address.clone().unwrap()
|
||||
)
|
||||
.as_str(),
|
||||
LogLevel::Warn,
|
||||
);
|
||||
device_address = None;
|
||||
}
|
||||
}
|
||||
|
||||
let mut count: i32 = 0;
|
||||
|
||||
if device_address.is_none() {
|
||||
loop {
|
||||
device_address = get_ip_from_mac(mac_address, network_address.as_str());
|
||||
if device_address.is_some() || count >= 10 {
|
||||
modify_config(|config| {
|
||||
let storage = config.storage.get_or_insert(Storage::default());
|
||||
storage.device_ip = device_address.clone().unwrap();
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
log(
|
||||
"main",
|
||||
"Couldn't find MAC adress in local network, sending awake call...",
|
||||
LogLevel::Info,
|
||||
);
|
||||
wake_on_land(mac_address);
|
||||
|
||||
log("main", "Waiting 30 seconds...", LogLevel::Info);
|
||||
count = count + 1;
|
||||
sleep(Duration::from_secs(30));
|
||||
}
|
||||
}
|
||||
|
||||
if device_address.is_none() {
|
||||
log(
|
||||
"main",
|
||||
"Couldn't find device address for MAC address.",
|
||||
LogLevel::Warn,
|
||||
);
|
||||
|
||||
if is_mounted_as(mount_point, mount_type) {
|
||||
log(
|
||||
"main",
|
||||
"Filesystem is mounted locally. Unmounting...",
|
||||
LogLevel::Info,
|
||||
);
|
||||
unmount(mount_point);
|
||||
}
|
||||
|
||||
mount_remote();
|
||||
} else {
|
||||
let dev_ip: String = device_address.unwrap();
|
||||
log(
|
||||
"main",
|
||||
"Found MAC address in local network.",
|
||||
LogLevel::Info,
|
||||
);
|
||||
|
||||
if !is_mounted_as(mount_point, mount_type) {
|
||||
if is_mounted(mount_point) {
|
||||
log(
|
||||
"main",
|
||||
"Filesystem is mounted. Unmounting...",
|
||||
LogLevel::Info,
|
||||
);
|
||||
unmount(mount_point);
|
||||
}
|
||||
|
||||
log("main", "Mounting local filesystem...", LogLevel::Info);
|
||||
mount(
|
||||
mount_point,
|
||||
&*format!("{}:{}", dev_ip, get_config().local.mount_path),
|
||||
mount_type,
|
||||
);
|
||||
} else {
|
||||
log(
|
||||
"main",
|
||||
"Filesystem is already mounted locally. Doing nothing.",
|
||||
LogLevel::Info,
|
||||
);
|
||||
let cli = cli::Cli::parse();
|
||||
match cli::dispatch(cli).await {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("Error: {e:#}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mount_remote() {
|
||||
let mount_point: &str = get_config().general.mount_point.as_str();
|
||||
let mount_type: &str = &get_config().remote.mount_type.as_str();
|
||||
|
||||
let mut could_reach: bool;
|
||||
let mut count: i32 = 0;
|
||||
|
||||
loop {
|
||||
could_reach = is_reachable("https://ping.creative-dragonslayer.de");
|
||||
|
||||
if could_reach || count >= 10 {
|
||||
break;
|
||||
}
|
||||
|
||||
log(
|
||||
"main",
|
||||
"Couldn't reach remote server, waiting 1 second.",
|
||||
LogLevel::Warn,
|
||||
);
|
||||
count = count + 1;
|
||||
sleep(Duration::from_secs(1));
|
||||
}
|
||||
|
||||
if could_reach {
|
||||
log("main", "Remote server reachable.", LogLevel::Info);
|
||||
|
||||
if mount_type == "davfs" || mount_type == "webdav" {
|
||||
save_credentials_webdav();
|
||||
}
|
||||
|
||||
if is_mounted_as(mount_point, get_config().local.mount_type.as_str())
|
||||
|| is_mounted_as(mount_point, mount_type)
|
||||
{
|
||||
log(
|
||||
"main",
|
||||
"Filesystem is already mounted. Doing nothing.",
|
||||
LogLevel::Info,
|
||||
);
|
||||
} else {
|
||||
if is_mounted(mount_point) {
|
||||
log(
|
||||
"main",
|
||||
"Filesystem is already mounted. Unmounting...",
|
||||
LogLevel::Info,
|
||||
);
|
||||
unmount(mount_point);
|
||||
}
|
||||
|
||||
log("main", "Mounting remote filesystem...", LogLevel::Info);
|
||||
mount(mount_point, &*get_config().remote.mount_path, mount_type);
|
||||
}
|
||||
} else {
|
||||
log("main", "Remote server not reachable.", LogLevel::Warn);
|
||||
fn parse_log_level(level: &str) -> logger_ctdra::LogLevel {
|
||||
match level.to_lowercase().as_str() {
|
||||
"error" => logger_ctdra::LogLevel::Error,
|
||||
"warn" => logger_ctdra::LogLevel::Warn,
|
||||
"debug" => logger_ctdra::LogLevel::Debug,
|
||||
_ => logger_ctdra::LogLevel::Info,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Pro-Paar-Dateisperre, damit ein manueller `mount --name X` nicht mit einem gleichzeitig
|
||||
//! laufenden `watch` für dasselbe Paar kollidiert.
|
||||
//!
|
||||
//! **Warum eine Datei-basierte Sperre (`flock(2)`) statt eines simplen `Mutex`:** smart-mount
|
||||
//! ist ein One-Shot-CLI (siehe [`crate::reconcile`]-Moduldoku) - jeder Aufruf ist ein eigener,
|
||||
//! kurzlebiger Prozess. Ein rein prozessinterner `Mutex<HashMap<...>>` schützt daher NICHT vor
|
||||
//! zwei gleichzeitig laufenden `smart-mount`-Prozessen (z. B. ein manuelles `unmount --name X`
|
||||
//! während ein systemd-Timer-`watch` läuft): beide bekämen ihre eigene, leere Registry und
|
||||
//! würden sich nie gegenseitig blockieren. Die Sperre muss daher außerhalb des Prozess-Speichers
|
||||
//! liegen - hier über eine `flock`-Datei pro Paar (`std::fs::File::lock()`, seit Rust 1.89
|
||||
//! Teil der Standardbibliothek, keine zusätzliche Abhängigkeit nötig).
|
||||
//!
|
||||
//! Ersetzt den globalen `RwLock`+`Mutex` aus dem alten `src/filesystem/mount.rs` (v0.2.0):
|
||||
//! dort war die Sperre prozessweit global, hier ist sie pro Laufwerkspaar - mehrere Paare
|
||||
//! können also parallel gemountet werden, ohne sich gegenseitig zu blockieren.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Hält die Sperrdatei offen (und damit die `flock`-Sperre über den zugehörigen
|
||||
/// Datei-Deskriptor), bis der Guard gedroppt wird - Schließen des Deskriptors gibt die Sperre
|
||||
/// implizit frei, ein explizites `unlock()` ist dafür nicht nötig.
|
||||
pub struct PairLock {
|
||||
_file: File,
|
||||
}
|
||||
|
||||
/// Verzeichnis für die Sperrdateien: `/run/smart-mount/locks` im Root-/System-Kontext (root ist
|
||||
/// dort ohnehin die einzige Partei, die Paare in diesem Kontext mountet), sonst
|
||||
/// `$XDG_RUNTIME_DIR` (per-Nutzer, von systemd `0700`-geschützt angelegt) mit Fallback auf das
|
||||
/// System-Temp-Verzeichnis, falls `$XDG_RUNTIME_DIR` nicht gesetzt ist (z. B. ohne aktive
|
||||
/// Login-Session).
|
||||
fn lock_dir() -> PathBuf {
|
||||
if sudo_ctdra::is_run_as_root() {
|
||||
PathBuf::from("/run/smart-mount/locks")
|
||||
} else {
|
||||
std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join("smart-mount-locks")
|
||||
}
|
||||
}
|
||||
|
||||
fn acquire_blocking(pair_id: &str) -> Result<PairLock> {
|
||||
let dir = lock_dir();
|
||||
std::fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
|
||||
|
||||
let path = dir.join(format!("{pair_id}.lock"));
|
||||
// `truncate(false)`: der Dateiinhalt ist irrelevant, nur der Deskriptor/die `flock`-Sperre
|
||||
// darauf zählt - ein Zurücksetzen auf leer bei jedem Aufruf wäre unnötig.
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.map_err(|e| Error::io(&path, e))?;
|
||||
|
||||
// Blockiert, bis die Sperre frei wird - `flock(2)` kennt keinen Async-Mechanismus, daher
|
||||
// läuft dieser gesamte Aufruf über `spawn_blocking` (siehe [`acquire`]) auf einem
|
||||
// Blocking-Thread statt einem Tokio-Worker-Thread.
|
||||
file.lock().map_err(|e| Error::io(&path, e))?;
|
||||
Ok(PairLock { _file: file })
|
||||
}
|
||||
|
||||
/// Sperrt ein Laufwerkspaar prozessübergreifend für die Dauer des zurückgegebenen Guards.
|
||||
pub async fn acquire(pair_id: &str) -> Result<PairLock> {
|
||||
let pair_id = pair_id.to_string();
|
||||
tokio::task::spawn_blocking(move || acquire_blocking(&pair_id))
|
||||
.await
|
||||
.map_err(|e| Error::Other(format!("lock task panicked: {e}")))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn acquire_returns_a_guard_and_releases_on_drop() {
|
||||
let id = format!("test-lock-{}", uuid::Uuid::new_v4());
|
||||
{
|
||||
let _guard = acquire(&id).await.expect("first acquire");
|
||||
}
|
||||
// Sollte nicht blockieren: der obige Guard wurde bereits gedroppt.
|
||||
let _guard2 = acquire(&id).await.expect("second acquire after drop");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_second_concurrent_acquire_waits_for_the_first_to_be_dropped() {
|
||||
let id = format!("test-lock-{}", uuid::Uuid::new_v4());
|
||||
let guard = acquire(&id).await.expect("first acquire");
|
||||
|
||||
let id2 = id.clone();
|
||||
let handle = tokio::spawn(async move { acquire(&id2).await });
|
||||
|
||||
// Kurz warten, damit der spawnte Task realistischerweise Zeit hatte, in `acquire` zu
|
||||
// blockieren, bevor die erste Sperre freigegeben wird.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
assert!(
|
||||
!handle.is_finished(),
|
||||
"second acquire must block while the first guard is held"
|
||||
);
|
||||
|
||||
drop(guard);
|
||||
handle
|
||||
.await
|
||||
.expect("task")
|
||||
.expect("second acquire after release");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! Mount-Backends für WebDAV/SMB/NFS mit dynamischem Dispatch.
|
||||
//!
|
||||
//! "Dynamisch nachladen" bedeutet hier: Trait-Object-Dispatch je nach [`MountKind`], und
|
||||
//! jedes Backend prüft sein benötigtes System-Binary (`mount.davfs`/`mount.cifs`/`mount.nfs`)
|
||||
//! erst, wenn es tatsächlich benutzt wird ([`MountBackend::check_available`]) - ein reiner
|
||||
//! WebDAV-Nutzer wird also nicht gezwungen, `cifs-utils`/`nfs-common` zu installieren.
|
||||
|
||||
pub mod lock;
|
||||
pub mod nfs;
|
||||
pub mod smb;
|
||||
pub mod state;
|
||||
pub mod target;
|
||||
pub mod webdav;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::{DrivePair, GlobalSettings, MountKind};
|
||||
use crate::db::credentials::{Credential, Side};
|
||||
use crate::error::Result;
|
||||
|
||||
/// Alle Informationen, die ein Backend braucht, um eine Seite eines Laufwerkspaars ein-
|
||||
/// bzw. auszuhängen. Der Mount-Aufruf ist immer `mount -t <type> <source> <target> -o <opts>`
|
||||
/// als root (smart-mount läuft ausschließlich als root/System-Dienst, siehe [`crate::systemd`]).
|
||||
pub struct MountTarget {
|
||||
pub pair_id: String,
|
||||
/// Welche Seite des Paars (lokal/cloud) dieses Ziel betrifft - bestimmt u. a. stabile
|
||||
/// Dateinamen für Credentials-/Secrets-Dateien.
|
||||
pub side: Side,
|
||||
pub mount_point: PathBuf,
|
||||
/// Vollständiger Quellstring, z. B. `//server/share`, `https://host/path`, `server:/export`.
|
||||
pub source: String,
|
||||
/// `-o`-Optionen als rohe Tokens (`"uid=1000"` oder bloße Flags wie `"soft"`) - mit Kommas
|
||||
/// verbindbar für `mount -o`.
|
||||
pub options: Vec<String>,
|
||||
/// Für Paare mit gesetztem `owner_user`: der Linux-Benutzername, dem Zugangsdaten-/
|
||||
/// Secrets-Dateien gehören sollen.
|
||||
pub owner_user: Option<String>,
|
||||
}
|
||||
|
||||
/// Gemeinsame Schnittstelle für WebDAV/SMB/NFS-Backends.
|
||||
pub trait MountBackend: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Prüft, ob das für dieses Backend nötige System-Binary vorhanden ist. Liefert bei
|
||||
/// Fehlen einen Fehler mit dem Namen des nachzuinstallierenden Pakets.
|
||||
fn check_available(&self) -> Result<()>;
|
||||
|
||||
/// Schreibt/aktualisiert alles, was der eigentliche `mount`-Aufruf voraussetzt
|
||||
/// (Credentials-/Secrets-Dateien, Config-Anpassungen wie davfs2s `gui_optimize`).
|
||||
fn prepare(&self, target: &MountTarget, cred: Option<&Credential>) -> Result<()>;
|
||||
|
||||
fn mount(&self, target: &MountTarget) -> Result<()>;
|
||||
fn unmount(&self, target: &MountTarget) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Wählt das passende Backend für einen [`MountKind`] (Trait-Object-Dispatch).
|
||||
pub fn backend_for(kind: MountKind) -> Box<dyn MountBackend> {
|
||||
match kind {
|
||||
MountKind::WebDav => Box::new(webdav::WebDavBackend),
|
||||
MountKind::Smb => Box::new(smb::SmbBackend),
|
||||
MountKind::Nfs => Box::new(nfs::NfsBackend),
|
||||
}
|
||||
}
|
||||
|
||||
/// Timeout in Sekunden für `mount`/`umount`-Subprozesse (siehe [`run_tolerating_already_done`]).
|
||||
/// Ein nicht mehr erreichbarer Server darf `smart-mount watch` niemals unbegrenzt blockieren -
|
||||
/// z. B. ist ein `umount` auf einem davfs2-Mount laut davfs2-eigener FAQ absichtlich so lange
|
||||
/// blockierend, bis alle zwischengespeicherten Daten geschrieben sind, was bei einem dauerhaft
|
||||
/// unerreichbaren Server sonst nie zurückkehren würde.
|
||||
pub(crate) const MOUNT_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
/// Führt einen `mount`/`umount`-Subprozess mit einem externen Timeout aus (über das
|
||||
/// coreutils-Tool `timeout`, auf jedem Linux-System vorhanden) und toleriert "bereits
|
||||
/// eingebunden"/"busy"/"nicht eingebunden" als No-op statt als Fehler (portiert aus dem alten
|
||||
/// `src/filesystem/mount.rs`, v0.2.0).
|
||||
///
|
||||
/// `tolerate_timeout`: bei `umount`-Aufrufen (`true`) wird ein durch den Timeout abgebrochener
|
||||
/// Versuch nur geloggt und als Erfolg gewertet - der nächste `watch`-Durchlauf versucht es
|
||||
/// erneut (idempotent, `umount` toleriert bereits "nicht eingebunden"). Bei `mount`-Aufrufen
|
||||
/// (`false`) ist ein Timeout ein echter Fehlschlag, da dabei nichts erfolgreich eingebunden
|
||||
/// wurde.
|
||||
pub(crate) fn run_tolerating_already_done(
|
||||
cmd: std::process::Command,
|
||||
context: &str,
|
||||
tolerate_timeout: bool,
|
||||
) -> Result<()> {
|
||||
run_tolerating_already_done_with_timeout(cmd, context, tolerate_timeout, MOUNT_TIMEOUT_SECS)
|
||||
}
|
||||
|
||||
fn run_tolerating_already_done_with_timeout(
|
||||
cmd: std::process::Command,
|
||||
context: &str,
|
||||
tolerate_timeout: bool,
|
||||
timeout_secs: u64,
|
||||
) -> Result<()> {
|
||||
let output =
|
||||
run_with_timeout(cmd, timeout_secs).map_err(|e| crate::error::Error::MountFailed {
|
||||
context: context.to_string(),
|
||||
stderr: e.to_string(),
|
||||
})?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// GNU coreutils `timeout` beendet sich mit Exit-Code 124, wenn es den Kindprozess wegen
|
||||
// Zeitüberschreitung abbrechen musste (dokumentiertes Verhalten von `timeout(1)`).
|
||||
if tolerate_timeout && output.status.code() == Some(124) {
|
||||
logger_ctdra::warn(
|
||||
"mount",
|
||||
&format!(
|
||||
"{context}: aborted after {timeout_secs}s (server likely unreachable) - will be retried on the next run"
|
||||
),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
|
||||
if stderr.contains("already mounted")
|
||||
|| stderr.contains("busy")
|
||||
|| stderr.contains("not mounted")
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(crate::error::Error::MountFailed {
|
||||
context: context.to_string(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Führt `cmd` über das coreutils-Tool `timeout` aus, damit ein hängender `mount`/`umount`
|
||||
/// den aufrufenden `smart-mount`-Prozess nie unbegrenzt blockiert.
|
||||
fn run_with_timeout(
|
||||
cmd: std::process::Command,
|
||||
timeout_secs: u64,
|
||||
) -> std::io::Result<std::process::Output> {
|
||||
let program = cmd.get_program().to_os_string();
|
||||
let args: Vec<_> = cmd.get_args().map(|a| a.to_os_string()).collect();
|
||||
std::process::Command::new("timeout")
|
||||
.arg(timeout_secs.to_string())
|
||||
.arg(program)
|
||||
.args(args)
|
||||
.output()
|
||||
}
|
||||
|
||||
/// Prüft per `which`, ob ein Binary im `PATH` auffindbar ist.
|
||||
pub(crate) fn binary_available(binary: &str) -> bool {
|
||||
std::process::Command::new("which")
|
||||
.arg(binary)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Entfernt (best-effort) die für `pair` auf beiden Seiten hinterlegten Klartext-
|
||||
/// Zugangsdaten, die `mount -t davfs`/`mount -t cifs` benötigen (NICHT die verschlüsselten
|
||||
/// DB-Zeilen - die werden separat über `CredentialStore::delete` entfernt). Für
|
||||
/// `smart-mount drive remove`, damit kein Passwort für ein gelöschtes Paar auf der Platte
|
||||
/// zurückbleibt. Fehler werden nur geloggt, nie propagiert: eine nicht perfekt aufräumbare
|
||||
/// Restdatei darf das Entfernen des Paars nicht blockieren.
|
||||
pub fn cleanup_credentials(pair: &DrivePair, settings: &GlobalSettings) {
|
||||
cleanup_side_credentials(pair, settings, Side::Local, pair.local.kind);
|
||||
cleanup_side_credentials(pair, settings, Side::Cloud, pair.cloud.kind);
|
||||
}
|
||||
|
||||
fn cleanup_side_credentials(
|
||||
pair: &DrivePair,
|
||||
settings: &GlobalSettings,
|
||||
side: Side,
|
||||
kind: MountKind,
|
||||
) {
|
||||
match kind {
|
||||
MountKind::Smb => {
|
||||
let path = smb::credentials_path(&pair.id, side);
|
||||
if path.exists()
|
||||
&& let Err(e) = std::fs::remove_file(&path)
|
||||
{
|
||||
logger_ctdra::warn(
|
||||
"mount",
|
||||
&format!(
|
||||
"Could not delete credentials file '{}': {e}",
|
||||
path.display()
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
MountKind::WebDav => {
|
||||
let source = match side {
|
||||
Side::Local => match target::local_source(&pair.local, settings) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
logger_ctdra::warn(
|
||||
"mount",
|
||||
&format!(
|
||||
"Could not resolve local source for pair '{}' to clean up its \
|
||||
davfs2 secrets entry: {e} - a stale plaintext credential may \
|
||||
remain on disk",
|
||||
pair.id
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
},
|
||||
Side::Cloud => target::cloud_source(&pair.cloud),
|
||||
};
|
||||
let path = webdav::davfs2_secrets_path();
|
||||
if let Err(e) = webdav::remove_secrets_entry(&path, &source) {
|
||||
logger_ctdra::warn(
|
||||
"mount",
|
||||
&format!("Could not remove secrets entry for '{source}': {e}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
MountKind::Nfs => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn run_with_timeout_kills_a_hanging_command_and_reports_exit_124() {
|
||||
let mut cmd = Command::new("sleep");
|
||||
cmd.arg("5");
|
||||
let output = run_with_timeout(cmd, 1).expect("timeout wrapper itself should run");
|
||||
// GNU coreutils `timeout` exits 124 when it had to kill the child - this is the
|
||||
// signal `run_tolerating_already_done_with_timeout` checks for below.
|
||||
assert_eq!(output.status.code(), Some(124));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_with_timeout_returns_promptly_for_a_command_that_finishes_in_time() {
|
||||
let cmd = Command::new("true");
|
||||
let started = std::time::Instant::now();
|
||||
let output = run_with_timeout(cmd, 10).expect("run");
|
||||
assert!(output.status.success());
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_is_tolerated_for_unmount_and_returns_ok() {
|
||||
let mut cmd = Command::new("sleep");
|
||||
cmd.arg("5");
|
||||
let result = run_tolerating_already_done_with_timeout(cmd, "test umount", true, 1);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_is_a_hard_error_for_mount() {
|
||||
let mut cmd = Command::new("sleep");
|
||||
cmd.arg("5");
|
||||
let result = run_tolerating_already_done_with_timeout(cmd, "test mount", false, 1);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! NFS-Backend (`mount.nfs`). In der Regel keine Zugangsdaten nötig - Autorisierung erfolgt
|
||||
//! serverseitig über Export-ACLs (`sec=sys`), optional `sec=krb5*` über `extra_options`.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
use crate::db::credentials::Credential;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::mount::{MountBackend, MountTarget, binary_available, run_tolerating_already_done};
|
||||
|
||||
pub struct NfsBackend;
|
||||
|
||||
impl MountBackend for NfsBackend {
|
||||
fn name(&self) -> &'static str {
|
||||
"nfs"
|
||||
}
|
||||
|
||||
fn check_available(&self) -> Result<()> {
|
||||
if binary_available("mount.nfs") || binary_available("mount.nfs4") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::BackendUnavailable {
|
||||
backend: "nfs",
|
||||
reason: "'mount.nfs'/'mount.nfs4' not found - install package 'nfs-common' (Debian/Ubuntu) or 'nfs-utils' (Fedora/Arch)".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare(&self, _target: &MountTarget, _cred: Option<&Credential>) -> Result<()> {
|
||||
// Normalerweise kein Vorbereitungsschritt nötig (kein Credentials-File).
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mount(&self, target: &MountTarget) -> Result<()> {
|
||||
let mut cmd = Command::new("mount");
|
||||
cmd.arg("-t")
|
||||
.arg("nfs")
|
||||
.arg(&target.source)
|
||||
.arg(&target.mount_point);
|
||||
if !target.options.is_empty() {
|
||||
cmd.arg("-o").arg(target.options.join(","));
|
||||
}
|
||||
run_tolerating_already_done(cmd, "nfs mount", false)
|
||||
}
|
||||
|
||||
fn unmount(&self, target: &MountTarget) -> Result<()> {
|
||||
let mut cmd = Command::new("umount");
|
||||
cmd.arg(&target.mount_point);
|
||||
run_tolerating_already_done(cmd, "nfs umount", true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! SMB/CIFS-Backend (`mount.cifs`).
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::db::credentials::Credential;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::mount::{MountBackend, MountTarget, binary_available, run_tolerating_already_done};
|
||||
|
||||
pub struct SmbBackend;
|
||||
|
||||
impl MountBackend for SmbBackend {
|
||||
fn name(&self) -> &'static str {
|
||||
"cifs"
|
||||
}
|
||||
|
||||
fn check_available(&self) -> Result<()> {
|
||||
if binary_available("mount.cifs") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::BackendUnavailable {
|
||||
backend: "cifs",
|
||||
reason: "'mount.cifs' not found - install package 'cifs-utils'".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare(&self, target: &MountTarget, cred: Option<&Credential>) -> Result<()> {
|
||||
if let Some(cred) = cred {
|
||||
write_credentials_file(&credentials_path(&target.pair_id, target.side), cred)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mount(&self, target: &MountTarget) -> Result<()> {
|
||||
let mut cmd = Command::new("mount");
|
||||
cmd.arg("-t")
|
||||
.arg("cifs")
|
||||
.arg(&target.source)
|
||||
.arg(&target.mount_point);
|
||||
let mut opts = target.options.clone();
|
||||
opts.push(format!(
|
||||
"credentials={}",
|
||||
credentials_path(&target.pair_id, target.side).display()
|
||||
));
|
||||
cmd.arg("-o").arg(opts.join(","));
|
||||
run_tolerating_already_done(cmd, "cifs mount", false)
|
||||
}
|
||||
|
||||
fn unmount(&self, target: &MountTarget) -> Result<()> {
|
||||
let mut cmd = Command::new("umount");
|
||||
cmd.arg(&target.mount_point);
|
||||
run_tolerating_already_done(cmd, "cifs umount", true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stabiler Pfad (nicht ein Tempfile!) - `prepare()` schreibt hierhin, `mount()` referenziert
|
||||
/// denselben Pfad über `-o credentials=...`.
|
||||
pub fn credentials_path(pair_id: &str, side: crate::db::credentials::Side) -> PathBuf {
|
||||
let base = config_ctdra::get_config_path()
|
||||
.parent()
|
||||
.map(|d| d.join("creds"))
|
||||
.unwrap_or_else(|| PathBuf::from("creds"));
|
||||
base.join(format!("{pair_id}-{}.cred", side.as_str()))
|
||||
}
|
||||
|
||||
/// `mount.cifs`s Credentials-Datei ist zeilenbasiert (`key=value`) - ein eingebettetes
|
||||
/// Newline in einem Feld würde eine zusätzliche, nicht vorgesehene `key=value`-Zeile
|
||||
/// einschleusen (z. B. ein überraschendes `domain=...` oder ein zweites `password=`, das das
|
||||
/// eigentliche überschreibt). Ein leeres Passwort ist ebenfalls kein sinnvoller Wert.
|
||||
fn validate_credential_field(name: &str, value: &str) -> Result<()> {
|
||||
if value.contains(['\n', '\r']) {
|
||||
return Err(Error::Other(format!(
|
||||
"cifs credential field '{name}' must not contain a newline"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_credentials_file(path: &PathBuf, cred: &Credential) -> Result<()> {
|
||||
if let Some(username) = &cred.username {
|
||||
validate_credential_field("username", username)?;
|
||||
}
|
||||
validate_credential_field("password", &cred.password)?;
|
||||
if cred.password.is_empty() {
|
||||
return Err(Error::Other(
|
||||
"cifs credential has an empty password".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(domain) = &cred.domain {
|
||||
validate_credential_field("domain", domain)?;
|
||||
}
|
||||
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
|
||||
}
|
||||
|
||||
let mut contents = String::new();
|
||||
if let Some(username) = &cred.username {
|
||||
contents.push_str(&format!("username={username}\n"));
|
||||
}
|
||||
contents.push_str(&format!("password={}\n", cred.password));
|
||||
if let Some(domain) = &cred.domain {
|
||||
contents.push_str(&format!("domain={domain}\n"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
let mut opts = OpenOptions::new();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let mut opts = OpenOptions::new();
|
||||
|
||||
let mut file = opts
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(path)
|
||||
.map_err(|e| Error::io(path, e))?;
|
||||
file.write_all(contents.as_bytes())
|
||||
.map_err(|e| Error::io(path, e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::credentials::Side;
|
||||
|
||||
#[test]
|
||||
fn writes_expected_credentials_format() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("test.cred");
|
||||
let cred = Credential {
|
||||
username: Some("nasuser".to_string()),
|
||||
domain: Some("WORKGROUP".to_string()),
|
||||
password: "s3cret".to_string(),
|
||||
};
|
||||
|
||||
write_credentials_file(&path, &cred).expect("write");
|
||||
let contents = std::fs::read_to_string(&path).expect("read");
|
||||
|
||||
assert!(contents.contains("username=nasuser"));
|
||||
assert!(contents.contains("password=s3cret"));
|
||||
assert!(contents.contains("domain=WORKGROUP"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_password_with_embedded_newline() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("test.cred");
|
||||
let cred = Credential {
|
||||
username: Some("nasuser".to_string()),
|
||||
domain: None,
|
||||
password: "s3cret\ndomain=INJECTED".to_string(),
|
||||
};
|
||||
|
||||
assert!(write_credentials_file(&path, &cred).is_err());
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_password() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("test.cred");
|
||||
let cred = Credential {
|
||||
username: Some("nasuser".to_string()),
|
||||
domain: None,
|
||||
password: String::new(),
|
||||
};
|
||||
|
||||
assert!(write_credentials_file(&path, &cred).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_path_differs_per_side() {
|
||||
let local = credentials_path("pair-1", Side::Local);
|
||||
let cloud = credentials_path("pair-1", Side::Cloud);
|
||||
assert_ne!(local, cloud);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Ermittelt den aktuellen Mount-Zustand eines Mountpoints durch Parsen von
|
||||
//! `/proc/self/mountinfo` - keine Zusatzabhängigkeit auf `findmnt`.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Ob `mount_point` aktuell eingebunden ist.
|
||||
pub fn is_mounted(mount_point: &Path) -> Result<bool> {
|
||||
Ok(current_source(mount_point)?.is_some())
|
||||
}
|
||||
|
||||
/// Die aktuell an `mount_point` eingebundene Quelle (die Spalte "source" aus mountinfo),
|
||||
/// oder `None`, falls dort nichts eingebunden ist.
|
||||
pub fn current_source(mount_point: &Path) -> Result<Option<String>> {
|
||||
let contents = std::fs::read_to_string("/proc/self/mountinfo")
|
||||
.map_err(|e| Error::io("/proc/self/mountinfo", e))?;
|
||||
Ok(parse_mountinfo_source(&contents, mount_point))
|
||||
}
|
||||
|
||||
/// Reine, testbare Parse-Funktion: `mountinfo`-Zeilenformat ist
|
||||
/// `... <mount_point> <mount_options> <optional fields> - <fs_type> <source> <super_options>`.
|
||||
/// Bei mehreren Treffern (verschachtelte Mounts) zählt der letzte (= zuletzt gemountete,
|
||||
/// aktuell sichtbare) Eintrag.
|
||||
fn parse_mountinfo_source(mountinfo: &str, mount_point: &Path) -> Option<String> {
|
||||
let target = mount_point.to_string_lossy();
|
||||
let mut result = None;
|
||||
|
||||
for line in mountinfo.lines() {
|
||||
let fields: Vec<&str> = line.split_whitespace().collect();
|
||||
// Feld 4 (Index 4) ist der Mountpoint; danach folgen optionale Felder bis zum
|
||||
// Trenner "-", danach fs_type (Index+1) und source (Index+2).
|
||||
//
|
||||
// Der Kernel escaped Leerzeichen/Tab/Newline/Backslash in Pfadfeldern oktal
|
||||
// (siehe `man 5 proc`, Abschnitt zu mountinfo) - ohne `unescape_octal_field` würde ein
|
||||
// Mountpoint mit z. B. einem Leerzeichen im Pfad hier nie als "gemountet" erkannt, weil
|
||||
// `\040` niemals gleich einem echten Leerzeichen ist.
|
||||
if fields.len() < 5 || unescape_octal_field(fields[4]) != target {
|
||||
continue;
|
||||
}
|
||||
let Some(dash_pos) = fields.iter().position(|&f| f == "-") else {
|
||||
continue;
|
||||
};
|
||||
if let Some(source) = fields.get(dash_pos + 2) {
|
||||
result = Some(source.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Kehrt die oktale Escape-Kodierung um, die der Kernel in `/proc/self/mountinfo` für
|
||||
/// Leerzeichen (`\040`), Tab (`\011`), Newline (`\012`) und Backslash (`\134`) in Pfadfeldern
|
||||
/// verwendet (siehe `man 5 proc`).
|
||||
fn unescape_octal_field(field: &str) -> std::borrow::Cow<'_, str> {
|
||||
if !field.contains('\\') {
|
||||
return std::borrow::Cow::Borrowed(field);
|
||||
}
|
||||
|
||||
// Arbeitet auf rohen Bytes statt `char`s: ein Byte einer mehrbytigen UTF-8-Sequenz per
|
||||
// `as char` in einen eigenständigen `char` umzuwandeln würde nicht-ASCII-Zeichen im Pfad
|
||||
// (z. B. Umlaute) in mehrere falsche Codepoints zerlegen.
|
||||
let bytes = field.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'\\'
|
||||
&& i + 3 < bytes.len()
|
||||
&& let Ok(octal) = std::str::from_utf8(&bytes[i + 1..i + 4])
|
||||
&& let Ok(value) = u8::from_str_radix(octal, 8)
|
||||
{
|
||||
out.push(value);
|
||||
i += 4;
|
||||
} else {
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
std::borrow::Cow::Owned(String::from_utf8_lossy(&out).into_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn finds_source_for_matching_mount_point() {
|
||||
let mountinfo = "36 35 98:0 / / rw,relatime shared:1 - ext4 /dev/root rw\n\
|
||||
43 36 0:26 / /home/dragon/smart-mount/pair-1 rw,relatime shared:2 - cifs //server/share rw,uid=1000";
|
||||
let source =
|
||||
parse_mountinfo_source(mountinfo, &PathBuf::from("/home/dragon/smart-mount/pair-1"));
|
||||
assert_eq!(source.as_deref(), Some("//server/share"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_unmounted_path() {
|
||||
let mountinfo = "36 35 98:0 / / rw,relatime shared:1 - ext4 /dev/root rw";
|
||||
let source =
|
||||
parse_mountinfo_source(mountinfo, &PathBuf::from("/home/dragon/smart-mount/pair-1"));
|
||||
assert_eq!(source, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_a_mount_point_containing_a_space_escaped_by_the_kernel() {
|
||||
let mountinfo =
|
||||
"43 36 0:26 / /mnt/my\\040drive rw,relatime shared:2 - cifs //server/share rw";
|
||||
let source = parse_mountinfo_source(mountinfo, &PathBuf::from("/mnt/my drive"));
|
||||
assert_eq!(source.as_deref(), Some("//server/share"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_matching_entry_wins_for_stacked_mounts() {
|
||||
let mountinfo = "36 35 98:0 / /mnt/x rw - nfs server:/export rw\n\
|
||||
37 36 0:26 / /mnt/x rw - davfs https://cloud/dav rw";
|
||||
let source = parse_mountinfo_source(mountinfo, &PathBuf::from("/mnt/x"));
|
||||
assert_eq!(source.as_deref(), Some("https://cloud/dav"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
//! Baut [`MountTarget`]s für eine Seite eines Paars und verwaltet den symlink-basierten
|
||||
//! "welche Seite ist aktiv"-Zustand.
|
||||
//!
|
||||
//! **Warum ein Symlink statt zwei fstab-Zeilen auf denselben Mountpoint:** `mount(8)`s
|
||||
//! Berechtigungsprüfung für unprivilegierte `user`-Mounts ist nur für den Fall EINER
|
||||
//! passenden fstab-Zeile dokumentiert (`man 8 mount`, Abschnitt "Non-superuser mounts",
|
||||
//! Beispiel `mount /cd`). Der Fall zweier `user,noauto`-Zeilen mit demselben Ziel, aber
|
||||
//! unterschiedlicher Quelle, ist nirgends spezifiziert - und `mount --fstab <alternative>`
|
||||
//! verlangt selbst Root, sodass sich das Verhalten nicht einmal gefahrlos in einer Sandbox
|
||||
//! verifizieren ließ. Um uns nicht auf unspezifiziertes Verhalten zu verlassen, bekommt jede
|
||||
//! Seite stattdessen ein eigenes, eindeutiges Backing-Verzeichnis mit genau einer fstab-Zeile
|
||||
//! (der dokumentierte, eindeutige Fall). Der konfigurierte `pair.mount_point` ist kein
|
||||
//! Mountpoint mehr, sondern ein Symlink, den smart-mount selbst atomar zwischen beiden
|
||||
//! Backing-Verzeichnissen umschaltet ([`activate_symlink`]). Zu jedem Zeitpunkt ist höchstens
|
||||
//! kurz während eines Umschaltens (siehe [`crate::reconcile`]) mehr als ein Backing-Verzeichnis
|
||||
//! gemountet - im Ruhezustand immer nur eines, wie ursprünglich vorgesehen.
|
||||
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::config::{CloudSide, DrivePair, GlobalSettings, LocalSide, MountKind};
|
||||
use crate::db::credentials::Side;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::mount::{self, MountTarget};
|
||||
use crate::network::address;
|
||||
|
||||
/// Eindeutiges Backing-Verzeichnis für eine Seite eines Paars - hier (und nur hier) wird
|
||||
/// tatsächlich `mount(8)` aufgerufen. Liegt als verstecktes Verzeichnis neben `pair.mount_point`.
|
||||
pub fn backing_dir(pair: &DrivePair, side: Side) -> PathBuf {
|
||||
let parent = pair.mount_point.parent().unwrap_or_else(|| Path::new("."));
|
||||
parent.join(format!(".{}-{}", pair.id, side.as_str()))
|
||||
}
|
||||
|
||||
/// Welches [`MountKind`] eine Seite eines Paars hat.
|
||||
pub fn side_kind(pair: &DrivePair, side: Side) -> MountKind {
|
||||
match side {
|
||||
Side::Local => pair.local.kind,
|
||||
Side::Cloud => pair.cloud.kind,
|
||||
}
|
||||
}
|
||||
|
||||
/// Baut ein [`MountTarget`] für eine Seite eines Paars, inkl. Auflösung der lokalen
|
||||
/// MAC-Adresse zu einer IP (siehe [`address::resolve_ip`]). `target.mount_point` ist das
|
||||
/// Backing-Verzeichnis (siehe [`backing_dir`]), nicht der sichtbare `pair.mount_point`.
|
||||
pub fn build_target(
|
||||
pair: &DrivePair,
|
||||
settings: &GlobalSettings,
|
||||
side: Side,
|
||||
) -> Result<MountTarget> {
|
||||
build_target_with_cached_local_ip(pair, settings, side, None)
|
||||
}
|
||||
|
||||
/// Wie [`build_target`], nimmt aber optional eine bereits aufgelöste lokale IP entgegen
|
||||
/// (`Side::Local` mit `LocalAddress::Mac`), um eine zweite `mac2ip`-Subprozess-Auflösung
|
||||
/// derselben Adresse innerhalb desselben Reconcile-Durchlaufs zu vermeiden (siehe
|
||||
/// [`crate::reconcile`], das die Erreichbarkeit ohnehin schon per `address::resolve_ip` prüft,
|
||||
/// bevor es ggf. auf diese Seite umschaltet). Wird für `Side::Cloud` ignoriert.
|
||||
pub fn build_target_with_cached_local_ip(
|
||||
pair: &DrivePair,
|
||||
settings: &GlobalSettings,
|
||||
side: Side,
|
||||
cached_local_ip: Option<std::net::Ipv4Addr>,
|
||||
) -> Result<MountTarget> {
|
||||
let kind = side_kind(pair, side);
|
||||
|
||||
let (source, mut options) = match side {
|
||||
Side::Local => (
|
||||
match cached_local_ip {
|
||||
Some(ip) => format_local_source(&pair.local, ip),
|
||||
None => local_source(&pair.local, settings)?,
|
||||
},
|
||||
parse_options(&pair.local.extra_options),
|
||||
),
|
||||
Side::Cloud => (
|
||||
cloud_source(&pair.cloud),
|
||||
parse_options(&pair.cloud.extra_options),
|
||||
),
|
||||
};
|
||||
apply_owner_permissions(kind, pair.owner_user.as_deref(), &mut options)?;
|
||||
apply_nfs_resilience_defaults(kind, &mut options);
|
||||
|
||||
Ok(MountTarget {
|
||||
pair_id: pair.id.clone(),
|
||||
side,
|
||||
mount_point: backing_dir(pair, side),
|
||||
source,
|
||||
options,
|
||||
owner_user: pair.owner_user.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Setzt `uid`/`gid`/`file_mode`/`dir_mode` für Protokolle ohne native Unix-Rechte (CIFS,
|
||||
/// WebDAV), damit `pair.owner_user` vollen Zugriff auf den Mount hat - inklusive
|
||||
/// Ausführrechten, damit dort liegende Skripte laufen können (`file_mode`/`dir_mode` steuern
|
||||
/// bei diesen Protokollen den simulierten `stat()`-Modus jeder Datei/jedes Verzeichnisses
|
||||
/// einheitlich; `0700` gibt ausschließlich dem Owner volle Rechte). Bereits in
|
||||
/// `extra_options` explizit gesetzte Werte werden respektiert und nicht überschrieben.
|
||||
///
|
||||
/// **NFS ist bewusst ausgenommen:** dort gibt es keine clientseitige `uid=`/`gid=`-Option -
|
||||
/// welcher lokale Nutzer Zugriff hat, bestimmt der NFS-Server über die tatsächlichen
|
||||
/// Datei-Eigentümer/-Rechte des Exports (siehe README, Abschnitt "Voraussetzungen").
|
||||
fn apply_owner_permissions(
|
||||
kind: MountKind,
|
||||
owner_user: Option<&str>,
|
||||
options: &mut Vec<String>,
|
||||
) -> Result<()> {
|
||||
if !matches!(kind, MountKind::Smb | MountKind::WebDav) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(owner) = owner_user else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if !has_option(options, "uid") || !has_option(options, "gid") {
|
||||
let (uid, gid) = resolve_uid_gid(owner)?;
|
||||
if !has_option(options, "uid") {
|
||||
options.push(format!("uid={uid}"));
|
||||
}
|
||||
if !has_option(options, "gid") {
|
||||
options.push(format!("gid={gid}"));
|
||||
}
|
||||
}
|
||||
if !has_option(options, "file_mode") {
|
||||
options.push("file_mode=0700".to_string());
|
||||
}
|
||||
if !has_option(options, "dir_mode") {
|
||||
options.push("dir_mode=0700".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Setzt `soft` als NFS-Standard, sofern der Nutzer nicht bereits selbst `hard`/`soft`/
|
||||
/// `softerr` gesetzt hat. `hard` (der Standard von `mount.nfs`, wenn nichts angegeben ist)
|
||||
/// lässt NFS-Anfragen unbegrenzt oft erneut versuchen, wenn der Server nicht antwortet -
|
||||
/// genau das Einfrierverhalten (auch bei `umount`), das automatisches Umschalten unmöglich
|
||||
/// machen würde. Siehe `man 5 nfs`, Abschnitt "soft / softerr / hard": dort wird ein
|
||||
/// dauerhaft nicht erreichbarer Server als der Anwendungsfall genannt, für den `soft`
|
||||
/// gedacht ist.
|
||||
fn apply_nfs_resilience_defaults(kind: MountKind, options: &mut Vec<String>) {
|
||||
if kind != MountKind::Nfs {
|
||||
return;
|
||||
}
|
||||
if !has_option(options, "hard")
|
||||
&& !has_option(options, "soft")
|
||||
&& !has_option(options, "softerr")
|
||||
{
|
||||
options.push("soft".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Prüft, ob `options` bereits einen Eintrag für `key` enthält - entweder als `key=wert`
|
||||
/// oder als bloßes Flag `key` (z. B. `soft`, `exec`).
|
||||
fn has_option(options: &[String], key: &str) -> bool {
|
||||
options
|
||||
.iter()
|
||||
.any(|o| o == key || o.starts_with(&format!("{key}=")))
|
||||
}
|
||||
|
||||
fn resolve_uid_gid(username: &str) -> Result<(u32, u32)> {
|
||||
Ok((run_id(username, "-u")?, run_id(username, "-g")?))
|
||||
}
|
||||
|
||||
/// Macht `owner` (Nutzer + dessen primäre Gruppe) zum alleinigen Besitzer von `dir` (`0700`).
|
||||
/// Für das Backing-Verzeichnis eines Paares mit gesetztem `owner_user` - unabhängig vom
|
||||
/// Mount-Typ, also auch für NFS, wo es (anders als bei CIFS/WebDAV) keine `uid=`/`gid=`-
|
||||
/// Mount-Option gibt, die die nach dem Mounten sichtbaren Rechte übersteuern könnte: das
|
||||
/// Verzeichnis selbst gehört so wenigstens `owner` - reicht z. B. aus, um es zu betreten, bevor/
|
||||
/// falls etwas gemountet ist, ersetzt aber nicht die serverseitige NFS-Export-Rechtevergabe
|
||||
/// (siehe README, Abschnitt "Voller Zugriff für einen bestimmten Nutzer").
|
||||
pub(crate) fn ensure_owner_only_dir(dir: &Path, owner: &str) -> Result<()> {
|
||||
let (uid, gid) = resolve_uid_gid(owner)?;
|
||||
std::os::unix::fs::chown(dir, Some(uid), Some(gid)).map_err(|e| Error::io(dir, e))?;
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
|
||||
.map_err(|e| Error::io(dir, e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_id(username: &str, flag: &str) -> Result<u32> {
|
||||
let output = std::process::Command::new("id")
|
||||
.arg(flag)
|
||||
.arg(username)
|
||||
.output()
|
||||
.map_err(|e| Error::Other(format!("could not run 'id': {e}")))?;
|
||||
if !output.status.success() {
|
||||
return Err(Error::Other(format!(
|
||||
"user '{username}' not found ('id {flag} {username}' failed): {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.map_err(|e| {
|
||||
Error::Other(format!(
|
||||
"unexpected output from 'id {flag} {username}': {e}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn local_source(local: &LocalSide, settings: &GlobalSettings) -> Result<String> {
|
||||
let ip = address::resolve_ip(&local.address, settings)?;
|
||||
Ok(format_local_source(local, ip))
|
||||
}
|
||||
|
||||
/// Formatiert die Mount-Quelle für eine bereits aufgelöste lokale IP, ohne selbst erneut
|
||||
/// aufzulösen - für Aufrufer, die die IP ohnehin schon für eine andere Prüfung (z. B.
|
||||
/// Erreichbarkeit) aufgelöst haben und damit eine zweite `mac2ip`-Auflösung derselben
|
||||
/// MAC-Adresse vermeiden wollen.
|
||||
pub fn format_local_source(local: &LocalSide, ip: std::net::Ipv4Addr) -> String {
|
||||
format_source(local.kind, &ip.to_string(), &local.share)
|
||||
}
|
||||
|
||||
pub fn cloud_source(cloud: &CloudSide) -> String {
|
||||
match cloud.kind {
|
||||
MountKind::WebDav => cloud.host_or_url.clone(),
|
||||
MountKind::Smb | MountKind::Nfs => {
|
||||
format_source(cloud.kind, &cloud.host_or_url, &cloud.share)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_source(kind: MountKind, host: &str, share: &str) -> String {
|
||||
let share = if share.starts_with('/') {
|
||||
share.to_string()
|
||||
} else {
|
||||
format!("/{share}")
|
||||
};
|
||||
match kind {
|
||||
MountKind::WebDav => format!("http://{host}{share}"),
|
||||
MountKind::Smb => format!("//{host}{share}"),
|
||||
MountKind::Nfs => format!("{host}:{share}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `extra_options` sind bereits einzelne Tokens (kein komma-getrennter String) - einfach
|
||||
/// übernehmen. Frühere Versionen filterten hier auf `key=value`-Paare, wodurch bloße Flags
|
||||
/// wie `soft`/`exec`/`ro` in `extra_options` still verworfen wurden - siehe Testfall unten.
|
||||
fn parse_options(extra: &[String]) -> Vec<String> {
|
||||
extra.to_vec()
|
||||
}
|
||||
|
||||
/// Welche Seite aktuell aktiv ist: `pair.mount_point` muss auf das Backing-Verzeichnis dieser
|
||||
/// Seite zeigen UND dieses Verzeichnis muss tatsächlich gemountet sein (Schutz gegen einen
|
||||
/// veralteten Symlink, dessen Backing-Verzeichnis extern ausgehängt wurde).
|
||||
pub fn active_side(pair: &DrivePair) -> Option<Side> {
|
||||
let link_target = std::fs::read_link(&pair.mount_point).ok()?;
|
||||
let side = if link_target == backing_dir(pair, Side::Local) {
|
||||
Side::Local
|
||||
} else if link_target == backing_dir(pair, Side::Cloud) {
|
||||
Side::Cloud
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
match mount::state::is_mounted(&backing_dir(pair, side)) {
|
||||
Ok(true) => Some(side),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Setzt `pair.mount_point` atomar als Symlink auf das Backing-Verzeichnis von `side`.
|
||||
///
|
||||
/// Atomar über `symlink` auf einen Temp-Pfad + `rename()` (POSIX-garantiert atomar auf
|
||||
/// demselben Dateisystem) - es gibt also kein Zeitfenster, in dem der Pfad fehlt oder auf ein
|
||||
/// veraltetes Ziel zeigt. Schlägt kontrolliert fehl (statt zu überschreiben), falls an
|
||||
/// `pair.mount_point` bereits ein echtes Verzeichnis (kein Symlink) existiert.
|
||||
pub fn activate_symlink(pair: &DrivePair, side: Side) -> Result<()> {
|
||||
let link_path = &pair.mount_point;
|
||||
|
||||
if let Ok(meta) = std::fs::symlink_metadata(link_path)
|
||||
&& !meta.file_type().is_symlink()
|
||||
{
|
||||
return Err(Error::Other(format!(
|
||||
"'{}' already exists as a real directory (not as a symlink managed by smart-mount) - \
|
||||
please remove/rename it manually before activating this pair.",
|
||||
link_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(parent) = link_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
|
||||
// `mount_base_dir` ist von allen Paaren (mit ggf. unterschiedlichem `owner_user`)
|
||||
// gemeinsam genutzt - hart auf `0755` setzen statt sich auf das Umask des root-Prozesses
|
||||
// zu verlassen, damit jeder lokale Nutzer bis zum eigentlichen Mountpoint durchqueren
|
||||
// kann (die eigentliche Zugriffsbeschränkung passiert weiter unten je Paar, siehe
|
||||
// `ensure_owner_only_dir`/die `uid=`/`gid=`-Mount-Optionen).
|
||||
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o755))
|
||||
.map_err(|e| Error::io(parent, e))?;
|
||||
}
|
||||
|
||||
let target = backing_dir(pair, side);
|
||||
let tmp_name = format!(
|
||||
".{}.smart-mount-tmp",
|
||||
link_path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default()
|
||||
);
|
||||
let tmp_path = link_path.with_file_name(tmp_name);
|
||||
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
std::os::unix::fs::symlink(&target, &tmp_path).map_err(|e| Error::io(&tmp_path, e))?;
|
||||
std::fs::rename(&tmp_path, link_path).map_err(|e| Error::io(link_path, e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
fn sample_pair() -> DrivePair {
|
||||
DrivePair {
|
||||
id: "pair-1".into(),
|
||||
name: "Test".into(),
|
||||
enabled: true,
|
||||
owner_user: None,
|
||||
mount_point: "/media/smart-mount/pair-1".into(),
|
||||
local: LocalSide {
|
||||
kind: MountKind::Smb,
|
||||
address: crate::config::LocalAddress::Ip(Ipv4Addr::new(192, 168, 1, 10)),
|
||||
share: "share".into(),
|
||||
username: None,
|
||||
extra_options: vec!["vers=3.0".into()],
|
||||
},
|
||||
cloud: CloudSide {
|
||||
kind: MountKind::WebDav,
|
||||
host_or_url: "https://cloud.example.com/dav".into(),
|
||||
share: "share".into(),
|
||||
username: None,
|
||||
extra_options: vec![],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_source_formats_smb_unc_path() {
|
||||
let pair = sample_pair();
|
||||
assert_eq!(
|
||||
local_source(&pair.local, &GlobalSettings::default()).expect("local_source"),
|
||||
"//192.168.1.10/share"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_source_propagates_mac2ip_error() {
|
||||
let mut pair = sample_pair();
|
||||
pair.local.address = crate::config::LocalAddress::Mac("00:11:22:33:44:55".into());
|
||||
let err = local_source(&pair.local, &GlobalSettings::default()).unwrap_err();
|
||||
assert!(matches!(err, crate::error::Error::Mac2Ip { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloud_source_uses_full_url_for_webdav() {
|
||||
let pair = sample_pair();
|
||||
assert_eq!(cloud_source(&pair.cloud), "https://cloud.example.com/dav");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_options_passes_flags_and_key_value_pairs_through_unchanged() {
|
||||
let opts = parse_options(&["vers=3.0".to_string(), "soft".to_string()]);
|
||||
assert_eq!(opts, vec!["vers=3.0".to_string(), "soft".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_owner_permissions_injects_uid_gid_and_owner_only_modes_for_cifs() {
|
||||
let user = std::env::var("USER").expect("USER env var set in test environment");
|
||||
let mut options = vec!["vers=3.0".to_string()];
|
||||
apply_owner_permissions(MountKind::Smb, Some(&user), &mut options).expect("apply");
|
||||
|
||||
assert!(options.contains(&"file_mode=0700".to_string()));
|
||||
assert!(options.contains(&"dir_mode=0700".to_string()));
|
||||
assert!(options.iter().any(|o| o.starts_with("uid=")));
|
||||
assert!(options.iter().any(|o| o.starts_with("gid=")));
|
||||
// vorhandene Option bleibt unangetastet
|
||||
assert!(options.contains(&"vers=3.0".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_owner_permissions_respects_explicit_overrides() {
|
||||
let user = std::env::var("USER").expect("USER env var set in test environment");
|
||||
let mut options = vec!["file_mode=0755".to_string()];
|
||||
apply_owner_permissions(MountKind::WebDav, Some(&user), &mut options).expect("apply");
|
||||
|
||||
assert!(options.contains(&"file_mode=0755".to_string()));
|
||||
assert!(!options.contains(&"file_mode=0700".to_string()));
|
||||
assert!(options.contains(&"dir_mode=0700".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_owner_permissions_is_noop_for_nfs() {
|
||||
let mut options = vec![];
|
||||
apply_owner_permissions(MountKind::Nfs, Some("root"), &mut options).expect("apply");
|
||||
assert!(options.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_owner_permissions_is_noop_without_owner_user() {
|
||||
let mut options = vec![];
|
||||
apply_owner_permissions(MountKind::Smb, None, &mut options).expect("apply");
|
||||
assert!(options.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_owner_permissions_fails_clearly_for_unknown_user() {
|
||||
let mut options = vec![];
|
||||
let err = apply_owner_permissions(MountKind::Smb, Some("no-such-user-xyz"), &mut options)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_nfs_resilience_defaults_adds_soft_when_unset() {
|
||||
let mut options = vec![];
|
||||
apply_nfs_resilience_defaults(MountKind::Nfs, &mut options);
|
||||
assert_eq!(options, vec!["soft".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_nfs_resilience_defaults_respects_explicit_hard() {
|
||||
let mut options = vec!["hard".to_string()];
|
||||
apply_nfs_resilience_defaults(MountKind::Nfs, &mut options);
|
||||
assert_eq!(options, vec!["hard".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_nfs_resilience_defaults_respects_explicit_softerr() {
|
||||
let mut options = vec!["softerr".to_string()];
|
||||
apply_nfs_resilience_defaults(MountKind::Nfs, &mut options);
|
||||
assert_eq!(options, vec!["softerr".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_nfs_resilience_defaults_is_noop_for_other_kinds() {
|
||||
let mut options = vec![];
|
||||
apply_nfs_resilience_defaults(MountKind::Smb, &mut options);
|
||||
assert!(options.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_target_injects_soft_for_plain_nfs_pair() {
|
||||
let mut pair = sample_pair();
|
||||
pair.local.kind = MountKind::Nfs;
|
||||
pair.cloud.kind = MountKind::Nfs;
|
||||
let t = build_target(&pair, &GlobalSettings::default(), Side::Local).expect("build");
|
||||
assert!(t.options.contains(&"soft".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backing_dirs_are_unique_per_side_and_live_next_to_mount_point() {
|
||||
let pair = sample_pair();
|
||||
let local = backing_dir(&pair, Side::Local);
|
||||
let cloud = backing_dir(&pair, Side::Cloud);
|
||||
assert_ne!(local, cloud);
|
||||
assert_eq!(local.parent(), pair.mount_point.parent());
|
||||
assert_eq!(cloud.parent(), pair.mount_point.parent());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activate_symlink_points_at_the_right_backing_dir_and_is_idempotent() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let mut pair = sample_pair();
|
||||
pair.mount_point = dir.path().join("pair-1");
|
||||
|
||||
activate_symlink(&pair, Side::Local).expect("activate local");
|
||||
assert_eq!(
|
||||
std::fs::read_link(&pair.mount_point).unwrap(),
|
||||
backing_dir(&pair, Side::Local)
|
||||
);
|
||||
|
||||
// Erneutes Aktivieren derselben Seite darf nicht fehlschlagen (Symlink wird ersetzt,
|
||||
// kein "existiert bereits als echtes Verzeichnis"-Fehler für einen eigenen Symlink).
|
||||
activate_symlink(&pair, Side::Local).expect("re-activate local");
|
||||
|
||||
activate_symlink(&pair, Side::Cloud).expect("activate cloud");
|
||||
assert_eq!(
|
||||
std::fs::read_link(&pair.mount_point).unwrap(),
|
||||
backing_dir(&pair, Side::Cloud)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activate_symlink_refuses_to_clobber_a_real_directory() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let mut pair = sample_pair();
|
||||
pair.mount_point = dir.path().join("pair-1");
|
||||
std::fs::create_dir_all(&pair.mount_point).expect("create real dir");
|
||||
|
||||
let err = activate_symlink(&pair, Side::Local).unwrap_err();
|
||||
assert!(err.to_string().contains("real directory"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_owner_only_dir_sets_mode_0700() {
|
||||
let user = std::env::var("USER").expect("USER env var set in test environment");
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
ensure_owner_only_dir(dir.path(), &user).expect("ensure_owner_only_dir");
|
||||
|
||||
let mode = std::fs::metadata(dir.path())
|
||||
.expect("metadata")
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o777, 0o700);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_owner_only_dir_fails_clearly_for_unknown_user() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let err = ensure_owner_only_dir(dir.path(), "no-such-user-xyz").unwrap_err();
|
||||
assert!(err.to_string().contains("not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activate_symlink_sets_shared_parent_dir_to_0755() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let parent = dir.path().join("mount-base");
|
||||
let mut pair = sample_pair();
|
||||
pair.mount_point = parent.join("pair-1");
|
||||
|
||||
activate_symlink(&pair, Side::Local).expect("activate local");
|
||||
|
||||
let mode = std::fs::metadata(&parent)
|
||||
.expect("metadata")
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o777, 0o755);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
//! WebDAV-Backend (davfs2).
|
||||
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::db::credentials::Credential;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::mount::{MountBackend, MountTarget, binary_available, run_tolerating_already_done};
|
||||
|
||||
pub struct WebDavBackend;
|
||||
|
||||
const GUI_OPTIMIZE_COMMENT: &str =
|
||||
"# smart-mount: gui_optimize enabled (batches PROPFIND requests for a directory)";
|
||||
|
||||
/// Deutlich über dem Standardwert (16), siehe [`ensure_buf_size`] für die Begründung.
|
||||
const BUF_SIZE_KIB: &str = "16384";
|
||||
const BUF_SIZE_COMMENT: &str = "# smart-mount: buf_size increased so directory contents are listed reliably even with many files";
|
||||
|
||||
impl MountBackend for WebDavBackend {
|
||||
fn name(&self) -> &'static str {
|
||||
"davfs2"
|
||||
}
|
||||
|
||||
fn check_available(&self) -> Result<()> {
|
||||
if binary_available("mount.davfs") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::BackendUnavailable {
|
||||
backend: "davfs2",
|
||||
reason: "'mount.davfs' not found - install package 'davfs2'".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare(&self, target: &MountTarget, cred: Option<&Credential>) -> Result<()> {
|
||||
let conf_path = davfs2_conf_path();
|
||||
ensure_gui_optimize(&conf_path)?;
|
||||
ensure_buf_size(&conf_path)?;
|
||||
if let Some(cred) = cred
|
||||
&& let Some(username) = &cred.username
|
||||
{
|
||||
if username.is_empty() || cred.password.is_empty() {
|
||||
return Err(Error::Other(
|
||||
"davfs2 credential has an empty username or password".to_string(),
|
||||
));
|
||||
}
|
||||
write_secrets_entry(
|
||||
&davfs2_secrets_path(),
|
||||
&target.source,
|
||||
username,
|
||||
&cred.password,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mount(&self, target: &MountTarget) -> Result<()> {
|
||||
let mut cmd = Command::new("mount");
|
||||
cmd.arg("-t")
|
||||
.arg("davfs")
|
||||
.arg(&target.source)
|
||||
.arg(&target.mount_point);
|
||||
if !target.options.is_empty() {
|
||||
cmd.arg("-o").arg(target.options.join(","));
|
||||
}
|
||||
run_tolerating_already_done(cmd, "davfs2 mount", false)
|
||||
}
|
||||
|
||||
fn unmount(&self, target: &MountTarget) -> Result<()> {
|
||||
let mut cmd = Command::new("umount");
|
||||
cmd.arg(&target.mount_point);
|
||||
run_tolerating_already_done(cmd, "davfs2 umount", true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mounten läuft ausschließlich als root/System-Dienst (siehe [`crate::systemd`]), daher immer
|
||||
/// der System-Pfad - kein Nutzerkontext-Zweig mehr nötig.
|
||||
fn davfs2_conf_path() -> PathBuf {
|
||||
PathBuf::from("/etc/davfs2/davfs2.conf")
|
||||
}
|
||||
|
||||
pub(crate) fn davfs2_secrets_path() -> PathBuf {
|
||||
PathBuf::from("/etc/davfs2/secrets")
|
||||
}
|
||||
|
||||
/// Setzt `gui_optimize 1` in `davfs2.conf` idempotent - reduziert bei grafischen
|
||||
/// Dateimanagern (die dazu neigen, jede Datei zu öffnen) die Reaktionszeit bei großen
|
||||
/// Verzeichnissen, indem die "ist eine neuere Version vorhanden?"-Abfrage für ein ganzes
|
||||
/// Verzeichnis in einem PROPFIND-Request gebündelt wird. Siehe `man davfs2.conf`.
|
||||
fn ensure_gui_optimize(path: &Path) -> Result<()> {
|
||||
ensure_config_line(path, "gui_optimize", "1", GUI_OPTIMIZE_COMMENT)
|
||||
}
|
||||
|
||||
/// Setzt `buf_size` in `davfs2.conf` idempotent auf einen deutlich über dem Standard (16
|
||||
/// KiB) liegenden Wert. Bekanntes Praxisproblem bei davfs2 (unabhängig davon, ob die
|
||||
/// `man davfs2.conf`-Beschreibung - reiner I/O-Geschwindigkeits-Tuningparameter - das exakt
|
||||
/// so vorsieht): bei zu kleinem `buf_size` liefert `ls` in Verzeichnissen mit vielen Dateien
|
||||
/// einen leeren/unvollständigen Inhalt zurück, obwohl einzelne Dateien direkt geöffnet werden
|
||||
/// können (der FUSE-readdir-Puffer wird dabei stillschweigend abgeschnitten). Ein deutlich
|
||||
/// größerer Puffer behebt das bei vertretbarem Speicher-Mehrverbrauch für einen einzelnen
|
||||
/// Mount.
|
||||
fn ensure_buf_size(path: &Path) -> Result<()> {
|
||||
ensure_config_line(path, "buf_size", BUF_SIZE_KIB, BUF_SIZE_COMMENT)
|
||||
}
|
||||
|
||||
/// Setzt `<key> <value>` in einer davfs2-Konfigurationsdatei idempotent: ersetzt eine
|
||||
/// bestehende (unkommentierte) Zeile mit demselben Schlüssel (unabhängig vom bisherigen
|
||||
/// Wert) statt sie zu duplizieren, und lässt alle anderen Zeilen unangetastet.
|
||||
fn ensure_config_line(path: &Path, key: &str, value: &str, comment: &str) -> Result<()> {
|
||||
let existing = fs::read_to_string(path).unwrap_or_default();
|
||||
|
||||
let is_key_line = |line: &str| {
|
||||
let trimmed = line.trim();
|
||||
!trimmed.starts_with('#') && trimmed.split_whitespace().next() == Some(key)
|
||||
};
|
||||
|
||||
if existing
|
||||
.lines()
|
||||
.any(|l| is_key_line(l) && l.split_whitespace().nth(1) == Some(value))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut new_lines: Vec<String> = existing
|
||||
.lines()
|
||||
.filter(|l| !is_key_line(l))
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
|
||||
new_lines.push(comment.to_string());
|
||||
new_lines.push(format!("{key} {value}"));
|
||||
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
|
||||
}
|
||||
fs::write(path, format!("{}\n", new_lines.join("\n"))).map_err(|e| Error::io(path, e))
|
||||
}
|
||||
|
||||
/// Erste (durch Leerraum getrennte oder in Anführungszeichen gefasste) Spalte einer
|
||||
/// `secrets`-Zeile - die URL, gegen die eine Zeile identifiziert wird.
|
||||
fn secrets_entry_url(line: &str) -> Option<&str> {
|
||||
let trimmed = line.trim_start();
|
||||
if let Some(rest) = trimmed.strip_prefix('"') {
|
||||
return rest.split('"').next();
|
||||
}
|
||||
trimmed.split_whitespace().next()
|
||||
}
|
||||
|
||||
/// davfs2 trennt Felder in der `secrets`-Datei per Leerraum; enthält ein Wert Leerraum, muss
|
||||
/// er nach `man davfs2.conf` in doppelte Anführungszeichen gefasst werden. Ohne diese
|
||||
/// Behandlung würde davfs2 ein Passwort mit Leerzeichen stillschweigend am ersten Leerzeichen
|
||||
/// abschneiden.
|
||||
fn quote_secrets_field(value: &str) -> String {
|
||||
if value.chars().any(char::is_whitespace) {
|
||||
format!("\"{}\"", value.replace('"', "\\\""))
|
||||
} else {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Schreibt/aktualisiert eine Zeile in davfs2s `secrets`-Datei (`<url> <username> <password>`,
|
||||
/// muss chmod 600 sein). Ersetzt eine bestehende Zeile für dieselbe URL statt sie zu duplizieren.
|
||||
fn write_secrets_entry(path: &PathBuf, url: &str, username: &str, password: &str) -> Result<()> {
|
||||
let existing = fs::read_to_string(path).unwrap_or_default();
|
||||
let mut lines: Vec<String> = existing
|
||||
.lines()
|
||||
.filter(|l| secrets_entry_url(l) != Some(url))
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
lines.push(format!(
|
||||
"{} {} {}",
|
||||
quote_secrets_field(url),
|
||||
quote_secrets_field(username),
|
||||
quote_secrets_field(password)
|
||||
));
|
||||
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
let mut opts = OpenOptions::new();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let mut opts = OpenOptions::new();
|
||||
|
||||
let mut file = opts
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(path)
|
||||
.map_err(|e| Error::io(path, e))?;
|
||||
file.write_all(format!("{}\n", lines.join("\n")).as_bytes())
|
||||
.map_err(|e| Error::io(path, e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Entfernt (best-effort) die Zeile für `url` aus der `secrets`-Datei, falls vorhanden - für
|
||||
/// `smart-mount drive remove`, damit kein Klartext-Passwort für ein gelöschtes Paar zurückbleibt.
|
||||
///
|
||||
/// **Bekannte Grenze:** die Zeile ist über die URL indiziert (inkl. IP bei MAC-adressierten
|
||||
/// lokalen Seiten). Hat sich die IP seit dem letzten Mount geändert, berechnet der Aufrufer
|
||||
/// eine andere, aktuelle URL als die tatsächlich gespeicherte - die eigentliche Alt-Zeile
|
||||
/// bleibt dann zurück. Kein Fehler, falls die Datei nicht existiert oder `url` nicht enthält.
|
||||
pub(crate) fn remove_secrets_entry(path: &Path, url: &str) -> Result<()> {
|
||||
let Ok(existing) = fs::read_to_string(path) else {
|
||||
return Ok(());
|
||||
};
|
||||
let remaining: Vec<&str> = existing
|
||||
.lines()
|
||||
.filter(|l| secrets_entry_url(l) != Some(url))
|
||||
.collect();
|
||||
if remaining.len() == existing.lines().count() {
|
||||
return Ok(());
|
||||
}
|
||||
fs::write(path, format!("{}\n", remaining.join("\n"))).map_err(|e| Error::io(path, e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ensure_gui_optimize_is_idempotent() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("davfs2.conf");
|
||||
|
||||
ensure_gui_optimize(&path).expect("first call");
|
||||
let first = fs::read_to_string(&path).expect("read");
|
||||
ensure_gui_optimize(&path).expect("second call");
|
||||
let second = fs::read_to_string(&path).expect("read");
|
||||
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(
|
||||
first
|
||||
.lines()
|
||||
.filter(|l| l.trim() == "gui_optimize 1")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_gui_optimize_replaces_disabled_value() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("davfs2.conf");
|
||||
fs::write(&path, "use_locks 1\ngui_optimize 0\n").expect("write");
|
||||
|
||||
ensure_gui_optimize(&path).expect("patch");
|
||||
let contents = fs::read_to_string(&path).expect("read");
|
||||
|
||||
assert!(contents.contains("use_locks 1"));
|
||||
assert!(contents.contains("gui_optimize 1"));
|
||||
assert!(!contents.contains("gui_optimize 0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_buf_size_raises_the_default_and_is_idempotent() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("davfs2.conf");
|
||||
fs::write(&path, "buf_size 16\n").expect("write default");
|
||||
|
||||
ensure_buf_size(&path).expect("first call");
|
||||
let first = fs::read_to_string(&path).expect("read");
|
||||
ensure_buf_size(&path).expect("second call");
|
||||
let second = fs::read_to_string(&path).expect("read");
|
||||
|
||||
assert_eq!(first, second);
|
||||
assert!(
|
||||
!first.contains("buf_size 16\n") && !first.lines().any(|l| l.trim() == "buf_size 16")
|
||||
);
|
||||
assert!(
|
||||
first
|
||||
.lines()
|
||||
.any(|l| l.trim() == format!("buf_size {BUF_SIZE_KIB}"))
|
||||
);
|
||||
assert_eq!(
|
||||
first
|
||||
.lines()
|
||||
.filter(|l| l.trim().starts_with("buf_size "))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gui_optimize_and_buf_size_coexist_without_clobbering_each_other() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("davfs2.conf");
|
||||
|
||||
ensure_gui_optimize(&path).expect("gui_optimize");
|
||||
ensure_buf_size(&path).expect("buf_size");
|
||||
|
||||
let contents = fs::read_to_string(&path).expect("read");
|
||||
assert!(contents.lines().any(|l| l.trim() == "gui_optimize 1"));
|
||||
assert!(
|
||||
contents
|
||||
.lines()
|
||||
.any(|l| l.trim() == format!("buf_size {BUF_SIZE_KIB}"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_secrets_entry_replaces_existing_line_for_same_url() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("secrets");
|
||||
|
||||
write_secrets_entry(&path, "https://cloud/dav", "user", "old-pass").expect("write 1");
|
||||
write_secrets_entry(&path, "https://cloud/dav", "user", "new-pass").expect("write 2");
|
||||
|
||||
let contents = fs::read_to_string(&path).expect("read");
|
||||
assert_eq!(
|
||||
contents
|
||||
.lines()
|
||||
.filter(|l| l.contains("https://cloud/dav"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(contents.contains("new-pass"));
|
||||
assert!(!contents.contains("old-pass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_secrets_entry_quotes_a_password_containing_whitespace() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("secrets");
|
||||
|
||||
write_secrets_entry(&path, "https://cloud/dav", "user", "my pass word").expect("write");
|
||||
|
||||
let contents = fs::read_to_string(&path).expect("read");
|
||||
assert!(contents.contains("\"my pass word\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_secrets_entry_does_not_clobber_a_url_that_is_a_prefix_of_another() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("secrets");
|
||||
|
||||
write_secrets_entry(&path, "http://192.168.1.5/dav", "user1", "pass1").expect("write 1");
|
||||
write_secrets_entry(&path, "http://192.168.1.5/dav-archive", "user2", "pass2")
|
||||
.expect("write 2");
|
||||
write_secrets_entry(&path, "http://192.168.1.5/dav", "user1", "pass1-updated")
|
||||
.expect("write 3");
|
||||
|
||||
let contents = fs::read_to_string(&path).expect("read");
|
||||
assert!(contents.contains("pass1-updated"));
|
||||
assert!(!contents.contains("pass1\n") && !contents.ends_with("pass1"));
|
||||
assert!(
|
||||
contents.contains("pass2"),
|
||||
"unrelated prefix-matching URL's entry must survive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_secrets_entry_deletes_only_the_matching_url() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("secrets");
|
||||
|
||||
write_secrets_entry(&path, "https://cloud/dav", "user1", "pass1").expect("write 1");
|
||||
write_secrets_entry(&path, "http://192.168.1.5/dav", "user2", "pass2").expect("write 2");
|
||||
|
||||
remove_secrets_entry(&path, "https://cloud/dav").expect("remove");
|
||||
let contents = fs::read_to_string(&path).expect("read");
|
||||
|
||||
assert!(!contents.contains("https://cloud/dav"));
|
||||
assert!(!contents.contains("pass1"));
|
||||
assert!(contents.contains("http://192.168.1.5/dav"));
|
||||
assert!(contents.contains("pass2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_secrets_entry_does_not_remove_a_url_that_the_target_url_is_a_prefix_of() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("secrets");
|
||||
|
||||
write_secrets_entry(&path, "http://192.168.1.5/dav", "user1", "pass1").expect("write 1");
|
||||
write_secrets_entry(&path, "http://192.168.1.5/dav-archive", "user2", "pass2")
|
||||
.expect("write 2");
|
||||
|
||||
remove_secrets_entry(&path, "http://192.168.1.5/dav").expect("remove");
|
||||
let contents = fs::read_to_string(&path).expect("read");
|
||||
|
||||
assert!(!contents.contains("pass1"));
|
||||
assert!(
|
||||
contents.contains("http://192.168.1.5/dav-archive") && contents.contains("pass2"),
|
||||
"removing the shorter URL must not delete the longer URL's entry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_secrets_entry_is_a_noop_for_missing_file_or_unmatched_url() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("does-not-exist");
|
||||
assert!(remove_secrets_entry(&path, "https://cloud/dav").is_ok());
|
||||
|
||||
write_secrets_entry(&path, "https://cloud/dav", "user", "pass").expect("write");
|
||||
assert!(remove_secrets_entry(&path, "https://other/dav").is_ok());
|
||||
let contents = fs::read_to_string(&path).expect("read");
|
||||
assert!(contents.contains("https://cloud/dav"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Auflösung der konfigurierten lokalen Adresse (IP oder MAC) zu einer konkreten IPv4-Adresse.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use crate::config::{GlobalSettings, LocalAddress};
|
||||
use crate::error::Result;
|
||||
use crate::network::mac2ip;
|
||||
|
||||
/// Löst eine [`LocalAddress`] zu einer konkreten IPv4-Adresse auf.
|
||||
///
|
||||
/// `Ip`-Adressen werden direkt durchgereicht, `Mac`-Adressen über das externe `mac2ip`-Tool
|
||||
/// aufgelöst (siehe [`mac2ip::resolve`]).
|
||||
pub fn resolve_ip(address: &LocalAddress, settings: &GlobalSettings) -> Result<Ipv4Addr> {
|
||||
match address {
|
||||
LocalAddress::Ip(ip) => Ok(*ip),
|
||||
LocalAddress::Mac(mac) => mac2ip::resolve(mac, &settings.mac2ip_binary),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Isolierter Wrapper um das externe, private `mac2ip`-CLI-Tool.
|
||||
//!
|
||||
//! `mac2ip` ist kein Rust-Crate, sondern ein eigenständiges, bereits vorhandenes CLI-Tool
|
||||
//! des Nutzers (siehe Projekt "Mac2Ip"). smart-mount ruft es ausschließlich als Subprozess
|
||||
//! auf; die JSON-Ausgabeform (`{"status":"ok","mac":..,"ip":..,"source":..}` bzw.
|
||||
//! `{"status":"error","mac":..,"error":..}`) wurde gegen den tatsächlichen Quellcode
|
||||
//! (`src/output.rs`) verifiziert.
|
||||
//!
|
||||
//! `--auto-trust-networks` wird immer mitgegeben: mac2ip beantwortet damit seine eigene
|
||||
//! "nmap-Scan in diesem Netzwerk erlauben?"-Rückfrage automatisch mit Ja und merkt sich das
|
||||
//! Netzwerk dauerhaft in seiner eigenen Cache-Datenbank - funktional identisch zum manuellen
|
||||
//! Eintragen in mac2ips Config, aber ohne dass smart-mount das Config-Schema eines fremden
|
||||
//! Tools kennen oder dort hineinschreiben muss.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
use std::process::Command;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Timeout in Sekunden für den `mac2ip`-Subprozess (dieselbe `timeout`-Coreutils-Technik wie
|
||||
/// [`crate::mount::run_with_timeout`]). `mac2ip` kann intern einen `nmap`-Scan auslösen -
|
||||
/// gegen ein dauerhaft nicht erreichbares/reagierendes Netz sonst ein unbegrenztes Hängen, das
|
||||
/// `smart-mount watch`/`status` komplett blockieren würde, statt korrekt "nicht auflösbar" zu
|
||||
/// melden.
|
||||
const MAC2IP_TIMEOUT_SECS: u64 = 15;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Mac2IpOutput {
|
||||
Success { ip: Ipv4Addr },
|
||||
Failure { error: String },
|
||||
}
|
||||
|
||||
/// Löst eine MAC-Adresse über das externe `mac2ip`-Tool zu einer IPv4-Adresse auf.
|
||||
///
|
||||
/// `binary` ist der konfigurierte Binary-Name/-Pfad (`GlobalSettings::mac2ip_binary`,
|
||||
/// standardmäßig `"mac2ip"`, per PATH aufgelöst).
|
||||
pub fn resolve(mac: &str, binary: &str) -> Result<Ipv4Addr> {
|
||||
let output = Command::new("timeout")
|
||||
.arg(MAC2IP_TIMEOUT_SECS.to_string())
|
||||
.arg(binary)
|
||||
.args(["--json", "--auto-trust-networks", mac])
|
||||
.output()
|
||||
.map_err(|e| Error::Mac2Ip {
|
||||
mac: mac.to_string(),
|
||||
reason: format!("could not run '{binary}': {e}"),
|
||||
})?;
|
||||
|
||||
if output.status.code() == Some(124) {
|
||||
return Err(Error::Mac2Ip {
|
||||
mac: mac.to_string(),
|
||||
reason: format!("'{binary}' did not respond within {MAC2IP_TIMEOUT_SECS}s (timed out)"),
|
||||
});
|
||||
}
|
||||
|
||||
parse_output(&output.stdout, mac)
|
||||
}
|
||||
|
||||
/// Prüft, ob das konfigurierte `mac2ip`-Binary über `PATH` auffindbar ist.
|
||||
pub fn is_installed(binary: &str) -> bool {
|
||||
Command::new("which")
|
||||
.arg(binary)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn parse_output(stdout: &[u8], mac: &str) -> Result<Ipv4Addr> {
|
||||
let text = String::from_utf8_lossy(stdout);
|
||||
let line = text.lines().next().unwrap_or("").trim();
|
||||
|
||||
if line.is_empty() {
|
||||
return Err(Error::Mac2Ip {
|
||||
mac: mac.to_string(),
|
||||
reason: "no output received from mac2ip".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
match serde_json::from_str::<Mac2IpOutput>(line) {
|
||||
Ok(Mac2IpOutput::Success { ip }) => Ok(ip),
|
||||
Ok(Mac2IpOutput::Failure { error }) => Err(Error::Mac2Ip {
|
||||
mac: mac.to_string(),
|
||||
reason: error,
|
||||
}),
|
||||
Err(e) => Err(Error::Mac2Ip {
|
||||
mac: mac.to_string(),
|
||||
reason: format!("could not parse output as JSON: {e} (output: '{line}')"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_success_output() {
|
||||
let stdout =
|
||||
br#"{"status":"ok","mac":"aa:bb:cc:dd:ee:ff","ip":"192.168.1.42","source":"cache"}"#;
|
||||
let ip = parse_output(stdout, "aa:bb:cc:dd:ee:ff").expect("should parse");
|
||||
assert_eq!(ip, Ipv4Addr::new(192, 168, 1, 42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_failure_output_as_error() {
|
||||
let stdout =
|
||||
br#"{"status":"error","mac":"aa:bb:cc:dd:ee:ff","error":"keine IP-Adresse gefunden"}"#;
|
||||
let err = parse_output(stdout, "aa:bb:cc:dd:ee:ff").unwrap_err();
|
||||
assert!(matches!(err, Error::Mac2Ip { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_output_is_an_error_not_a_panic() {
|
||||
assert!(parse_output(b"", "aa:bb:cc:dd:ee:ff").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_output_is_an_error_not_a_panic() {
|
||||
assert!(parse_output(b"not json at all", "aa:bb:cc:dd:ee:ff").is_err());
|
||||
}
|
||||
}
|
||||
+89
-2
@@ -1,2 +1,89 @@
|
||||
pub mod network_interface;
|
||||
pub mod utils;
|
||||
//! Erreichbarkeitsprüfungen und lokale Adressauflösung.
|
||||
|
||||
pub mod address;
|
||||
pub mod mac2ip;
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// Prüft, ob `addr` erreichbar ist. Erkennt anhand des Präfixes, ob es sich um eine URL
|
||||
/// (HTTP HEAD via `curl`) oder eine reine Host-/IP-Adresse (ICMP-Ping) handelt.
|
||||
///
|
||||
/// Portiert aus dem alten `src/network/utils.rs` (v0.2.0).
|
||||
///
|
||||
/// Für HTTP wird bewusst NICHT `curl --fail` verwendet: ein WebDAV-Server, der auf das HEAD
|
||||
/// mit 401/403 (Auth erforderlich) antwortet, ist trotzdem erreichbar - `--fail` würde das als
|
||||
/// Fehler werten. Der tatsächliche Statuscode wird stattdessen ausgelesen und geprüft: nur ein
|
||||
/// Serverfehler (5xx) gilt als NICHT erreichbar, da ein dauerhaft 5xx-antwortender Server
|
||||
/// (z. B. ein kaputter Reverse-Proxy) sonst bei jedem `watch`-Durchlauf einen vollen,
|
||||
/// letztlich erfolglosen Umschaltversuch (inkl. `MOUNT_TIMEOUT_SECS`-Wartezeit) auslösen würde.
|
||||
pub fn is_reachable(addr: &str) -> bool {
|
||||
if addr.starts_with("http://") || addr.starts_with("https://") {
|
||||
Command::new("curl")
|
||||
.args([
|
||||
"--head",
|
||||
"--silent",
|
||||
"--max-time",
|
||||
"5",
|
||||
"--output",
|
||||
"/dev/null",
|
||||
"--write-out",
|
||||
"%{http_code}",
|
||||
addr,
|
||||
])
|
||||
.stderr(Stdio::null())
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
String::from_utf8_lossy(&o.stdout)
|
||||
.trim()
|
||||
.parse::<u16>()
|
||||
.ok()
|
||||
})
|
||||
.is_some_and(|code| (100..500).contains(&code))
|
||||
} else {
|
||||
Command::new("ping")
|
||||
.args(["-c", "1", "-W", "2", addr])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use std::net::TcpListener;
|
||||
|
||||
#[test]
|
||||
fn http_401_endpoint_is_reported_as_reachable() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||
let port = listener.local_addr().expect("port").port();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
if let Ok((mut stream, _)) = listener.accept() {
|
||||
let response = "HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n";
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
}
|
||||
});
|
||||
|
||||
assert!(is_reachable(&format!("http://127.0.0.1:{port}")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_500_endpoint_is_reported_as_unreachable() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||
let port = listener.local_addr().expect("port").port();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
if let Ok((mut stream, _)) = listener.accept() {
|
||||
let response = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n";
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
}
|
||||
});
|
||||
|
||||
assert!(!is_reachable(&format!("http://127.0.0.1:{port}")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
use crate::log;
|
||||
use crate::log::LogLevel;
|
||||
use std::process::Command;
|
||||
|
||||
/// Prüft, ob mindestens eine verfügbare Netzwerkschnittstelle (LAN/WLAN etc.) aktiv ist
|
||||
/// und nicht nur die Loopback-Schnittstelle.
|
||||
///
|
||||
/// Rückgabe:
|
||||
/// - Option<String>: Name der aktiven Netzwerkkarte oder None wenn keine gefunden
|
||||
pub fn get_active_network_interface() -> Option<String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
// Prüfe Interfaces in /sys/class/net
|
||||
let sysfs = Path::new("/sys/class/net");
|
||||
if let Ok(entries) = fs::read_dir(sysfs) {
|
||||
for entry in entries.flatten() {
|
||||
let ifname = match entry.file_name().into_string() {
|
||||
Ok(n) => n,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if ifname == "lo" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let iface_path = entry.path();
|
||||
|
||||
// 1) operstate == "up"
|
||||
let operstate = fs::read_to_string(iface_path.join("operstate"))
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
if operstate != "up" {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2) carrier == "1" (hat Link)
|
||||
let carrier = fs::read_to_string(iface_path.join("carrier"))
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
if carrier != "1" {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3) Virtuell ausschließen:
|
||||
// /sys/class/net/<if>/device -> Symlink zu physischer HW;
|
||||
// zeigt der Pfad unterhalb von ".../virtual/..." oder existiert er gar nicht,
|
||||
// dann ist das i. d. R. ein virtuelles Interface.
|
||||
let device_link = iface_path.join("device");
|
||||
let is_virtual = match fs::read_link(&device_link) {
|
||||
Ok(target) => target.as_os_str().to_string_lossy().contains("/virtual/"),
|
||||
Err(_) => true, // kein device-Link -> in der Regel virtuell
|
||||
};
|
||||
if is_virtual {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Optional: bekannte „logische" Typen via Verzeichnis-Erkennung ausschließen
|
||||
// (Bridges, VLANs, Macvlan etc.)
|
||||
if iface_path.join("bridge").exists()
|
||||
|| iface_path.join("bonding").exists()
|
||||
|| iface_path.join("team").exists()
|
||||
|| iface_path.join("vlan").exists()
|
||||
|| iface_path.join("macvlan").exists()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Wenn wir hier sind, haben wir eine aktive physische NIC
|
||||
log::log("network_interface", "Found active network interface.", LogLevel::Info);
|
||||
return Some(ifname);
|
||||
}
|
||||
}
|
||||
|
||||
let output = Command::new("ip")
|
||||
.args(["-o", "link", "show", "up"])
|
||||
.output();
|
||||
|
||||
if let Ok(out) = output {
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
for line in stdout.lines() {
|
||||
if let Some(rest) = line.splitn(2, ": ").nth(1) {
|
||||
if let Some(ifname) = rest.split(':').next() {
|
||||
let name = ifname.trim();
|
||||
if name != "lo" {
|
||||
log::log("network_interface", "Found active network interface.", LogLevel::Debug);
|
||||
return Some(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Windows: PowerShell verwenden, um aktive Adapter zu finden (Status = Up).
|
||||
// Wir ignorieren Loopback/Pseudo-Interfaces, indem wir Physical=true filtern.
|
||||
let ps_cmd = r#"
|
||||
$adapters = Get-NetAdapter -Physical | Where-Object {$_.Status -eq 'Up'} | Select-Object -First 1
|
||||
if ($adapters) { $adapters.Name } else { 'NONE' }
|
||||
"#;
|
||||
let output = Command::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
|
||||
.output();
|
||||
|
||||
if let Ok(out) = output {
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
|
||||
if stdout != "NONE" {
|
||||
log::log("network_interface", "Found active network interface.", LogLevel::Debug);
|
||||
return Some(stdout);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
log::log("network_interface", "OS not supported.", LogLevel::Error);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Ermittelt die IP-Adresse (mit Netzmaske) für eine angegebene Netzwerkschnittstelle
|
||||
///
|
||||
/// # Parameter
|
||||
/// - `interface`: Name der Netzwerkschnittstelle
|
||||
///
|
||||
/// # Rückgabe
|
||||
/// - Option<String>: IP-Adresse mit Netzmaske (z.B. "192.168.1.100/24") oder None wenn keine gefunden
|
||||
pub fn get_interface_ip_address(interface: &str) -> Option<String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let output = Command::new("ip")
|
||||
.args(["addr", "show", interface])
|
||||
.output();
|
||||
|
||||
if let Ok(out) = output {
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
for line in stdout.lines() {
|
||||
if line.contains("inet ") {
|
||||
if let Some(ip) = line.split_whitespace().nth(1) {
|
||||
return Some(ip.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let ps_cmd = format!(
|
||||
r#"Get-NetIPAddress -InterfaceAlias '{}' -AddressFamily IPv4 | Select-Object IPAddress,PrefixLength | ForEach-Object {{ "{0}/{1}" -f $_.IPAddress,$_.PrefixLength }}"#,
|
||||
interface
|
||||
);
|
||||
let output = Command::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
|
||||
.output();
|
||||
|
||||
if let Ok(out) = output {
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if !stdout.is_empty() {
|
||||
return Some(stdout);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
log::log("network_interface", "OS not supported.", LogLevel::Error);
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
use crate::log;
|
||||
use crate::log::LogLevel;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::process::Command;
|
||||
|
||||
/// Prüft, ob eine angegebene IP-Adresse oder URL erreichbar ist
|
||||
///
|
||||
/// # Parameter
|
||||
/// - `address`: Eine IP-Adresse oder vollständige HTTPS-URL
|
||||
///
|
||||
/// # Rückgabe
|
||||
/// - `bool`: True wenn die Adresse erreichbar ist, False wenn nicht
|
||||
pub fn is_reachable(address: &str) -> bool {
|
||||
let addr_string = if let Some((ip_str, mask_str)) = address.split_once('/') {
|
||||
if ip_str.parse::<Ipv4Addr>().is_ok()
|
||||
&& mask_str.parse::<u8>().ok().filter(|m| *m <= 32).is_some()
|
||||
{
|
||||
let ip = ip_str.parse::<Ipv4Addr>().unwrap();
|
||||
let next_ip = Ipv4Addr::from(u32::from(ip).saturating_add(1));
|
||||
next_ip.to_string()
|
||||
} else {
|
||||
address.to_string()
|
||||
}
|
||||
} else {
|
||||
address.to_string()
|
||||
};
|
||||
let addr = addr_string.as_str();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if addr.parse::<Ipv4Addr>().is_ok() {
|
||||
let output = Command::new("ping")
|
||||
.args(["-c", "1", "-W", "2", addr])
|
||||
.output();
|
||||
|
||||
if let Ok(out) = output {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
log("network_utils", &*stdout, LogLevel::Debug);
|
||||
|
||||
out.status.success()
|
||||
} else {
|
||||
log::log("network_utils", &*format!("Couldn't reach address {}!", addr), LogLevel::Error);
|
||||
false
|
||||
}
|
||||
} else {
|
||||
let output = Command::new("curl")
|
||||
.args(["--head", "--silent", "--fail", addr])
|
||||
.output();
|
||||
|
||||
if let Ok(out) = output {
|
||||
out.status.success()
|
||||
} else {
|
||||
log::log("network_utils", &*format!("Couldn't reach address {}!", addr), LogLevel::Error);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if addr.parse::<Ipv4Addr>().is_ok() {
|
||||
let output = Command::new("ping")
|
||||
.args(["-n", "1", "-w", "2000", addr])
|
||||
.output();
|
||||
|
||||
if let Ok(out) = output {
|
||||
out.status.success()
|
||||
} else {
|
||||
log::log("network_utils", &*format!("Couldn't reach address {}!", addr), LogLevel::Error);
|
||||
false
|
||||
}
|
||||
} else {
|
||||
let output = Command::new("powershell")
|
||||
.args(["-Command", &format!("Invoke-WebRequest -Uri {} -Method HEAD -UseBasicParsing", addr)])
|
||||
.output();
|
||||
|
||||
if let Ok(out) = output {
|
||||
out.status.success()
|
||||
} else {
|
||||
log::log("network_utils", &*format!("Couldn't reach address {}!", addr), LogLevel::Error);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
log::log("network_utils", "OS not supported.", LogLevel::Error);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Berechnet die Netzwerkadresse aus einer IP-Adresse mit Subnetzmaske
|
||||
///
|
||||
/// # Parameter
|
||||
/// - `address`: Eine IPv4-Adresse mit Subnetzmaske im Format "xxx.xxx.xxx.xxx/yy"
|
||||
///
|
||||
/// # Rückgabe
|
||||
/// - Option<String>: Die Netzwerkadresse im gleichen Format oder None bei ungültiger Eingabe
|
||||
pub fn get_network_address(address: &str) -> Option<String> {
|
||||
let parts: Vec<&str> = address.split('/').collect();
|
||||
if parts.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Ok(ip) = parts[0].parse::<Ipv4Addr>() {
|
||||
if let Ok(mask) = parts[1].parse::<u8>() {
|
||||
if mask > 32 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ip_bits: u32 = u32::from(ip);
|
||||
let mask_bits: u32 = !0u32 << (32 - mask);
|
||||
let network = Ipv4Addr::from(ip_bits & mask_bits);
|
||||
|
||||
return Some(format!("{}/{}", network, mask));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
/// Ermittelt die IP-Adresse zu einer gegebenen MAC-Adresse im lokalen Netzwerk.
|
||||
/// Verwendet einen nmap-Scan, um das Gerät zu finden.
|
||||
///
|
||||
/// # Parameter
|
||||
/// - `mac`: MAC-Adresse im Format "xx:xx:xx:xx:xx:xx"
|
||||
/// - `network`: Netzwerkadresse im Format "xxx.xxx.xxx.xxx/yy"
|
||||
///
|
||||
/// # Rückgabe
|
||||
/// - Option<String>: Die IP-Adresse des Geräts oder None wenn nicht gefunden
|
||||
pub fn get_ip_from_mac(mac: &str, network: &str) -> Option<String> {
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
{
|
||||
let output = Command::new("nmap")
|
||||
.args(["-sn", "-n", "--system-dns", "-PR", network])
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(out) => {
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
log("network_utils", &*stdout, LogLevel::Debug);
|
||||
|
||||
// MAC normalisieren: nur Hex-Zeichen, klein, ohne Trennzeichen
|
||||
let normalize_mac = |m: &str| -> String {
|
||||
m.chars()
|
||||
.filter(|c| c.is_ascii_hexdigit())
|
||||
.flat_map(|c| c.to_lowercase())
|
||||
.collect::<String>()
|
||||
};
|
||||
let target_mac = normalize_mac(mac);
|
||||
|
||||
// Beispielausgaben:
|
||||
// Nmap scan report for 192.168.0.10
|
||||
// Nmap scan report for printer.lan (192.168.0.10)
|
||||
// MAC Address: 00:11:22:33:44:55 (Vendor)
|
||||
//
|
||||
// Strategie:
|
||||
// - Merke die zuletzt gesehene IP aus "Nmap scan report for ..."
|
||||
// - Wenn danach eine "MAC Address:"-Zeile folgt und MAC passt -> gib IP zurück
|
||||
let mut current_ip: Option<String> = None;
|
||||
|
||||
for raw_line in stdout.lines() {
|
||||
let line = raw_line.trim();
|
||||
|
||||
if line.starts_with("Nmap scan report for") {
|
||||
// Versuche IP zu extrahieren:
|
||||
// 1) Klammerform: ... (x.x.x.x)
|
||||
// 2) Sonst letztes Token als IP
|
||||
let ip = if let Some(start) = line.rfind('(') {
|
||||
if let Some(end) = line.rfind(')') {
|
||||
let in_parens = &line[start + 1..end];
|
||||
Some(in_parens.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
line.split_whitespace()
|
||||
.last()
|
||||
.map(|s| s.to_string())
|
||||
};
|
||||
|
||||
// Grobe Validierung IPv4
|
||||
if let Some(ip_str) = ip {
|
||||
let maybe_ip = ip_str.parse::<Ipv4Addr>().ok().map(|ip| ip.to_string());
|
||||
current_ip = maybe_ip;
|
||||
} else {
|
||||
current_ip = None;
|
||||
}
|
||||
} else if line.starts_with("MAC Address:") {
|
||||
// Format: "MAC Address: XX:XX:XX:XX:XX:XX (Vendor)"
|
||||
let mac_part = line.strip_prefix("MAC Address:").unwrap().trim();
|
||||
let mac_token = mac_part.split_whitespace().next().unwrap_or("");
|
||||
let seen_mac = normalize_mac(mac_token);
|
||||
|
||||
if !seen_mac.is_empty() && seen_mac == target_mac {
|
||||
if let Some(ip) = current_ip.clone() {
|
||||
return Some(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
log::log("network_utils", "nmap is not installed on the system!", LogLevel::Error);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
log::log("network_utils", "OS not supported.", LogLevel::Error);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Ermittelt die MAC-Adresse zu einer gegebenen IP-Adresse im lokalen Netzwerk
|
||||
/// durch Auslesen der ARP-Tabelle.
|
||||
///
|
||||
/// # Parameter
|
||||
/// - `ip`: IP-Adresse für die die MAC-Adresse ermittelt werden soll
|
||||
///
|
||||
/// # Rückgabe
|
||||
/// - Option<String>: Die MAC-Adresse des Geräts oder None wenn nicht gefunden
|
||||
pub fn get_mac_from_ip(ip: &str) -> Option<String> {
|
||||
// Hilfsfunktionen zur MAC-Normalisierung
|
||||
let normalize_mac = |m: &str| -> String {
|
||||
m.chars()
|
||||
.filter(|c| c.is_ascii_hexdigit())
|
||||
.flat_map(|c| c.to_lowercase())
|
||||
.collect::<String>()
|
||||
};
|
||||
let canonicalize_mac = |hex_no_sep: &str| -> String {
|
||||
hex_no_sep
|
||||
.as_bytes()
|
||||
.chunks(2)
|
||||
.map(std::str::from_utf8)
|
||||
.filter_map(Result::ok)
|
||||
.collect::<Vec<&str>>()
|
||||
.join(":")
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// 1) Ziel kurz anpingen, damit ein ARP-Eintrag entsteht
|
||||
let _ = Command::new("ping")
|
||||
.args(["-c", "1", "-W", "1", ip])
|
||||
.output();
|
||||
|
||||
// 2) Bis zu drei Versuche, ARP/Neigh einzulesen (kleine Wartezeit)
|
||||
for _ in 0..3 {
|
||||
let output = Command::new("ip")
|
||||
.args(["neigh", "show", ip])
|
||||
.output();
|
||||
|
||||
if let Ok(out) = output {
|
||||
if out.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
// Beispiel: 192.168.1.1 dev eth0 lladdr 00:11:22:33:44:55 REACHABLE
|
||||
for line in stdout.lines() {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.get(0).copied() == Some(ip) {
|
||||
if let Some(idx) = parts.iter().position(|p| *p == "lladdr") {
|
||||
if let Some(mac_tok) = parts.get(idx + 1) {
|
||||
let seen_norm = normalize_mac(mac_tok);
|
||||
if seen_norm.len() == 12 {
|
||||
return Some(canonicalize_mac(&seen_norm));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// 1) Ziel kurz anpingen, damit ein ARP-Eintrag entsteht
|
||||
let _ = Command::new("ping")
|
||||
.args(["-n", "1", "-w", "500", ip])
|
||||
.output();
|
||||
|
||||
// 2) Bis zu drei Versuche, ARP einzulesen (kleine Wartezeit)
|
||||
for _ in 0..3 {
|
||||
// Hinweis: `arp -a` auf Windows zeigt die gesamte Tabelle; mit IP filtert es i. d. R. auf Interface,
|
||||
// daher filtern wir inhaltlich auf die Zeile mit der Ziel-IP.
|
||||
let output = Command::new("arp")
|
||||
.args(["-a"])
|
||||
.output();
|
||||
|
||||
if let Ok(out) = output {
|
||||
if out.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
for line in stdout.lines() {
|
||||
let cols: Vec<&str> = line.split_whitespace().collect();
|
||||
// Typisch: "192.168.1.1 00-11-22-33-44-55 dynamic"
|
||||
if cols.get(0).copied() == Some(ip) && cols.len() >= 2 {
|
||||
let mac_colon = cols[1].replace('-', ":");
|
||||
let seen_norm = normalize_mac(&mac_colon);
|
||||
if seen_norm.len() == 12 {
|
||||
return Some(canonicalize_mac(&seen_norm));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
log::log("network_utils", "OS not supported.", LogLevel::Error);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Sendet ein Wake-on-LAN Magic Packet an eine MAC-Adresse
|
||||
///
|
||||
/// # Parameter
|
||||
/// - `mac`: MAC-Adresse im Format "xx:xx:xx:xx:xx:xx"
|
||||
///
|
||||
/// # Funktionsweise
|
||||
/// Sendet ein Magic Packet bestehend aus:
|
||||
/// - 6 Bytes 0xFF (Synchronisierung)
|
||||
/// - 16x die MAC-Adresse wiederholt
|
||||
/// - Port 9 (Standard Wake-on-LAN Port)
|
||||
pub fn wake_on_land(mac: &str) {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let output = Command::new("wakeonlan")
|
||||
.arg(mac)
|
||||
.output();
|
||||
|
||||
if let Ok(out) = output {
|
||||
if !out.status.success() {
|
||||
log("network_utils", "Failed to send WOL packet!", LogLevel::Error);
|
||||
}
|
||||
} else {
|
||||
log("network_utils", "wakeonlan is not installed on the system!", LogLevel::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let ps_cmd = format!(
|
||||
r#"
|
||||
$mac="{}"
|
||||
$macByteArray=[byte[]]::new(102)
|
||||
6..101 | %{{ $macByteArray[$_]=0xFF }}
|
||||
$macAddr=[byte[]]::new(6)
|
||||
$mac -split "[:-]" | %{{$macAddr[$i]=[Convert]::ToByte($_,16); $i++}}
|
||||
6..101 | %{{ $macByteArray[$_]=$macAddr[($_ - 6) % 6] }}
|
||||
$UdpClient=New-Object System.Net.Sockets.UdpClient
|
||||
$UdpClient.Connect(([System.Net.IPAddress]::Broadcast),9)
|
||||
$UdpClient.Send($macByteArray,$macByteArray.Length)
|
||||
"#,
|
||||
mac
|
||||
);
|
||||
|
||||
let output = Command::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
|
||||
.output();
|
||||
|
||||
if let Err(_) = output {
|
||||
log::log("network_utils", "Failed to send WOL packet!", LogLevel::Error);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
log::log("network_utils", "OS not supported.", LogLevel::Error);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
use std::env;
|
||||
|
||||
/// Liefert den Programmnamen (Dateistamm der aktuellen ausführbaren Datei).
|
||||
///
|
||||
/// Rückgabe:
|
||||
/// - Dateistamm der aktuellen Executable als `String`.
|
||||
/// - Fallback `"app"`, wenn der Name nicht ermittelt werden kann.
|
||||
pub(crate) fn program_name() -> String {
|
||||
env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()))
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "app".to_string())
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
//! Watchdog-Reconciling: entscheidet pro Laufwerkspaar, ob lokal oder Cloud eingebunden
|
||||
//! sein sollte, und schaltet bei Bedarf um.
|
||||
//!
|
||||
//! `watch_once` ist bewusst ein einzelner, synchroner Durchlauf ohne internen Sleep-Loop -
|
||||
//! dieselbe Kommandozeile (`smart-mount watch`) funktioniert dadurch sowohl unter einem
|
||||
//! systemd-Timer als auch als Crontab-Zeile.
|
||||
//!
|
||||
//! **Zu jedem Zeitpunkt ist im Ruhezustand genau eine Seite gemountet** (wie ursprünglich
|
||||
//! vorgesehen) - der sichtbare `pair.mount_point` ist ein Symlink auf das jeweils aktive
|
||||
//! Backing-Verzeichnis (siehe [`crate::mount::target`]). Nur *während* eines aktiven
|
||||
//! Umschaltens sind kurz beide Backing-Verzeichnisse gemountet: [`switch_to`] mountet die
|
||||
//! neue Seite zuerst, flippt dann den Symlink, und hängt erst danach die alte Seite aus - so
|
||||
//! gibt es nie ein Zeitfenster, in dem der sichtbare Pfad auf nichts Gemountetes zeigt.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use crate::config::{AppConfig, DrivePair, GlobalSettings, LocalSide};
|
||||
use crate::db::credentials::{CredentialStore, Side};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::mount::{self, lock, target};
|
||||
use crate::network::{self, address};
|
||||
|
||||
/// Ergebnis eines Reconcile-Durchlaufs für ein Paar.
|
||||
#[derive(Debug)]
|
||||
pub enum Action {
|
||||
NoOp,
|
||||
MountedLocal,
|
||||
MountedCloud,
|
||||
SwitchedToLocal,
|
||||
SwitchedToCloud,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ReconcileOutcome {
|
||||
pub pair_id: String,
|
||||
pub pair_name: String,
|
||||
pub action: Action,
|
||||
}
|
||||
|
||||
/// Führt `reconcile_pair` für jedes aktivierte Paar in `cfg` aus.
|
||||
///
|
||||
/// Die Erreichbarkeitsprüfungen (Ping/`curl`-Subprozesse, bis zu mehrere Sekunden pro Seite)
|
||||
/// sind rein lesend und unabhängig voneinander - sie laufen daher parallel über
|
||||
/// `spawn_blocking` statt sequenziell, damit die Gesamtlaufzeit eines `watch`-Durchlaufs bei
|
||||
/// vielen/langsamen Paaren nicht linear mit deren Anzahl wächst. Das eigentliche Mounten/
|
||||
/// Aushängen bleibt dagegen sequenziell: es teilt sich den `CredentialStore` und schreibt
|
||||
/// Zustand (Symlinks, Credential-/Secrets-Dateien) - die Pro-Paar-Sperre (siehe [`lock`])
|
||||
/// verhindert zwar Kollisionen, unbegrenzt paralleler DB-/Dateizugriff wäre aber unnötiges
|
||||
/// Risiko für einen Effizienzgewinn, den die Reachability-Parallelisierung bereits liefert.
|
||||
pub async fn watch_once(cfg: &AppConfig, creds: &CredentialStore) -> Vec<ReconcileOutcome> {
|
||||
let enabled: Vec<&DrivePair> = cfg.pairs.iter().filter(|p| p.enabled).collect();
|
||||
|
||||
let mut checks = Vec::with_capacity(enabled.len());
|
||||
for pair in &enabled {
|
||||
let local = pair.local.clone();
|
||||
let cloud_host = pair.cloud.host_or_url.clone();
|
||||
let settings = cfg.settings.clone();
|
||||
checks.push(tokio::task::spawn_blocking(move || {
|
||||
let (local_reachable, local_ip) = check_local_reachable(&local, &settings);
|
||||
let cloud_reachable = network::is_reachable(&cloud_host);
|
||||
(local_reachable, local_ip, cloud_reachable)
|
||||
}));
|
||||
}
|
||||
|
||||
let mut outcomes = Vec::with_capacity(enabled.len());
|
||||
for (pair, check) in enabled.iter().zip(checks) {
|
||||
let (local_reachable, local_ip, cloud_reachable) =
|
||||
check.await.unwrap_or((false, None, false));
|
||||
outcomes.push(
|
||||
reconcile_pair_checked(
|
||||
pair,
|
||||
&cfg.settings,
|
||||
creds,
|
||||
local_reachable,
|
||||
local_ip,
|
||||
cloud_reachable,
|
||||
)
|
||||
.await,
|
||||
);
|
||||
}
|
||||
outcomes
|
||||
}
|
||||
|
||||
/// Entscheidungstabelle (siehe Moduldoku): prüft Erreichbarkeit beider Seiten, vergleicht
|
||||
/// mit der aktuell aktiven Seite, und schaltet bei Bedarf um.
|
||||
pub async fn reconcile_pair(
|
||||
pair: &DrivePair,
|
||||
settings: &GlobalSettings,
|
||||
creds: &CredentialStore,
|
||||
) -> ReconcileOutcome {
|
||||
let (local_reachable, local_ip) = check_local_reachable(&pair.local, settings);
|
||||
let cloud_reachable = network::is_reachable(&pair.cloud.host_or_url);
|
||||
reconcile_pair_checked(
|
||||
pair,
|
||||
settings,
|
||||
creds,
|
||||
local_reachable,
|
||||
local_ip,
|
||||
cloud_reachable,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn reconcile_pair_checked(
|
||||
pair: &DrivePair,
|
||||
settings: &GlobalSettings,
|
||||
creds: &CredentialStore,
|
||||
local_reachable: bool,
|
||||
local_ip: Option<Ipv4Addr>,
|
||||
cloud_reachable: bool,
|
||||
) -> ReconcileOutcome {
|
||||
let pair_id = pair.id.clone();
|
||||
let pair_name = pair.name.clone();
|
||||
|
||||
match reconcile_pair_inner(
|
||||
pair,
|
||||
settings,
|
||||
creds,
|
||||
local_reachable,
|
||||
local_ip,
|
||||
cloud_reachable,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(action) => ReconcileOutcome {
|
||||
pair_id,
|
||||
pair_name,
|
||||
action,
|
||||
},
|
||||
Err(e) => ReconcileOutcome {
|
||||
pair_id,
|
||||
pair_name,
|
||||
action: Action::Failed(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn reconcile_pair_inner(
|
||||
pair: &DrivePair,
|
||||
settings: &GlobalSettings,
|
||||
creds: &CredentialStore,
|
||||
local_reachable: bool,
|
||||
local_ip: Option<Ipv4Addr>,
|
||||
cloud_reachable: bool,
|
||||
) -> Result<Action> {
|
||||
let _guard = lock::acquire(&pair.id).await?;
|
||||
|
||||
let active = target::active_side(pair);
|
||||
cleanup_orphaned_mounts(pair, settings, active).await;
|
||||
|
||||
if local_reachable {
|
||||
if active == Some(Side::Local) {
|
||||
return Ok(Action::NoOp);
|
||||
}
|
||||
switch_to(pair, settings, Side::Local, creds, active, local_ip).await?;
|
||||
return Ok(if active.is_some() {
|
||||
Action::SwitchedToLocal
|
||||
} else {
|
||||
Action::MountedLocal
|
||||
});
|
||||
}
|
||||
|
||||
if cloud_reachable {
|
||||
if active == Some(Side::Cloud) {
|
||||
return Ok(Action::NoOp);
|
||||
}
|
||||
switch_to(pair, settings, Side::Cloud, creds, active, local_ip).await?;
|
||||
return Ok(if active.is_some() {
|
||||
Action::SwitchedToCloud
|
||||
} else {
|
||||
Action::MountedCloud
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Action::NoOp)
|
||||
}
|
||||
|
||||
/// Prüft die Erreichbarkeit der lokalen Seite und gibt dabei - falls erfolgreich aufgelöst -
|
||||
/// die konkrete IP zurück, damit ein nachfolgender `switch_to(..., Side::Local, ...)` dieselbe
|
||||
/// MAC-Adresse nicht ein zweites Mal über `mac2ip` auflösen muss.
|
||||
fn check_local_reachable(local: &LocalSide, settings: &GlobalSettings) -> (bool, Option<Ipv4Addr>) {
|
||||
match address::resolve_ip(&local.address, settings) {
|
||||
Ok(ip) => (network::is_reachable(&ip.to_string()), Some(ip)),
|
||||
Err(_) => (false, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Räumt einen Mount auf der jeweils NICHT aktiven Seite auf, falls einer besteht.
|
||||
///
|
||||
/// [`target::active_side`] identifiziert "die" aktive Seite ausschließlich über den sichtbaren
|
||||
/// Symlink - ein Mount auf der jeweils anderen Seite (z. B. weil eine vorherige
|
||||
/// `activate_symlink`-Aktivierung mitten im Umschalten abgebrochen wurde, oder weil extern
|
||||
/// manuell gemountet wurde) bliebe dadurch unbemerkt: `status`/`watch` sähen ihn nie, und er
|
||||
/// würde nie automatisch wieder ausgehängt. Best-effort (nur geloggt, nicht propagiert) - ein
|
||||
/// hier fehlschlagendes Aufräumen darf den eigentlichen Reconcile-Schritt nicht blockieren.
|
||||
async fn cleanup_orphaned_mounts(
|
||||
pair: &DrivePair,
|
||||
settings: &GlobalSettings,
|
||||
active: Option<Side>,
|
||||
) {
|
||||
for side in [Side::Local, Side::Cloud] {
|
||||
if Some(side) == active {
|
||||
continue;
|
||||
}
|
||||
let dir = target::backing_dir(pair, side);
|
||||
match mount::state::is_mounted(&dir) {
|
||||
Ok(true) => {
|
||||
logger_ctdra::warn(
|
||||
"reconcile",
|
||||
&format!(
|
||||
"pair '{}': found an orphaned mount on the inactive side '{}' (not \
|
||||
reflected by the active symlink) - unmounting it",
|
||||
pair.id,
|
||||
side.as_str()
|
||||
),
|
||||
);
|
||||
if let Err(e) = unmount_side(pair, settings, side).await {
|
||||
logger_ctdra::warn(
|
||||
"reconcile",
|
||||
&format!(
|
||||
"pair '{}': could not clean up orphaned mount on side '{}': {e}",
|
||||
pair.id,
|
||||
side.as_str()
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => logger_ctdra::warn(
|
||||
"reconcile",
|
||||
&format!(
|
||||
"pair '{}': could not check mount state for side '{}': {e}",
|
||||
pair.id,
|
||||
side.as_str()
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mountet `new_side` zuerst, flippt danach den sichtbaren Symlink, und hängt erst zum
|
||||
/// Schluss `old_active` (falls vorhanden und verschieden) aus. Diese Reihenfolge stellt
|
||||
/// sicher, dass der sichtbare `pair.mount_point` nie auf ein gerade ausgehängtes oder noch
|
||||
/// nicht bereites Backing-Verzeichnis zeigt.
|
||||
async fn switch_to(
|
||||
pair: &DrivePair,
|
||||
settings: &GlobalSettings,
|
||||
new_side: Side,
|
||||
creds: &CredentialStore,
|
||||
old_active: Option<Side>,
|
||||
cached_local_ip: Option<Ipv4Addr>,
|
||||
) -> Result<()> {
|
||||
mount_side(pair, settings, new_side, creds, cached_local_ip).await?;
|
||||
|
||||
// Falls das Aktivieren des Symlinks fehlschlägt, muss die gerade gemountete `new_side`
|
||||
// wieder ausgehängt werden, statt sie unbemerkt gemountet zu lassen: der nächste
|
||||
// Reconcile-Durchlauf sähe sonst weiterhin `old_active` als aktive Seite (der Symlink zeigt
|
||||
// ja weiter darauf) und würde `new_side` nie wieder aushängen - ein dauerhaft verwaister
|
||||
// Mount.
|
||||
if let Err(e) = target::activate_symlink(pair, new_side) {
|
||||
let _ = unmount_side(pair, settings, new_side).await;
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
if let Some(old_side) = old_active
|
||||
&& old_side != new_side
|
||||
{
|
||||
unmount_side(pair, settings, old_side).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mount_side(
|
||||
pair: &DrivePair,
|
||||
settings: &GlobalSettings,
|
||||
side: Side,
|
||||
creds: &CredentialStore,
|
||||
cached_local_ip: Option<Ipv4Addr>,
|
||||
) -> Result<()> {
|
||||
let mount_target =
|
||||
target::build_target_with_cached_local_ip(pair, settings, side, cached_local_ip)?;
|
||||
std::fs::create_dir_all(&mount_target.mount_point)
|
||||
.map_err(|e| crate::error::Error::io(&mount_target.mount_point, e))?;
|
||||
// Backing-Verzeichnis auf `owner_user` chownen (0700) - relevant vor allem für NFS, wo es
|
||||
// (anders als CIFS/WebDAV) keine `uid=`/`gid=`-Mount-Option gibt, die den Zugriff nach dem
|
||||
// Mounten sonst regeln würde (siehe `target::ensure_owner_only_dir`).
|
||||
if let Some(owner) = &pair.owner_user {
|
||||
target::ensure_owner_only_dir(&mount_target.mount_point, owner)?;
|
||||
}
|
||||
|
||||
let backend = mount::backend_for(target::side_kind(pair, side));
|
||||
backend.check_available()?;
|
||||
let cred = creds.get(&pair.id, side).await?;
|
||||
backend.prepare(&mount_target, cred.as_ref())?;
|
||||
backend.mount(&mount_target)
|
||||
}
|
||||
|
||||
/// Hängt aus, was aktuell aktiv ist (siehe [`target::active_side`]), unabhängig von der
|
||||
/// Erreichbarkeit - für `smart-mount unmount`. Der Symlink bleibt bestehen (zeigt danach auf
|
||||
/// ein leeres, ausgehängtes Backing-Verzeichnis) - der nächste `mount`/`watch`-Lauf räumt das
|
||||
/// beim erneuten Aktivieren automatisch auf.
|
||||
pub async fn unmount_pair(pair: &DrivePair, settings: &GlobalSettings) -> Result<Action> {
|
||||
let _guard = lock::acquire(&pair.id).await?;
|
||||
|
||||
// Beide Seiten werden unabhängig voneinander versucht - ein Fehler (auch ein
|
||||
// `is_mounted`-Prüffehler) bei der ersten Seite darf die zweite nicht ungeprüft
|
||||
// überspringen. Der erste aufgetretene Fehler wird nach beiden Versuchen zurückgegeben,
|
||||
// damit der Aufrufer weiterhin erkennt, dass etwas fehlgeschlagen ist.
|
||||
let mut first_err: Option<Error> = None;
|
||||
for side in [Side::Local, Side::Cloud] {
|
||||
let dir = target::backing_dir(pair, side);
|
||||
match mount::state::is_mounted(&dir) {
|
||||
Ok(true) => {
|
||||
if let Err(e) = unmount_side(pair, settings, side).await
|
||||
&& first_err.is_none()
|
||||
{
|
||||
first_err = Some(e);
|
||||
}
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
logger_ctdra::warn(
|
||||
"reconcile",
|
||||
&format!(
|
||||
"pair '{}': could not check mount state for side '{}': {e} - still \
|
||||
attempting the other side",
|
||||
pair.id,
|
||||
side.as_str()
|
||||
),
|
||||
);
|
||||
if first_err.is_none() {
|
||||
first_err = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match first_err {
|
||||
Some(e) => Err(e),
|
||||
None => Ok(Action::NoOp),
|
||||
}
|
||||
}
|
||||
|
||||
async fn unmount_side(pair: &DrivePair, settings: &GlobalSettings, side: Side) -> Result<()> {
|
||||
let mount_target = target::build_target(pair, settings, side).unwrap_or_else(|e| {
|
||||
logger_ctdra::warn(
|
||||
"reconcile",
|
||||
&format!(
|
||||
"pair '{}': could not fully resolve the mount target for side '{}' while \
|
||||
unmounting ({e}) - unmounting its backing directory anyway with best-effort \
|
||||
defaults",
|
||||
pair.id,
|
||||
side.as_str()
|
||||
),
|
||||
);
|
||||
crate::mount::MountTarget {
|
||||
pair_id: pair.id.clone(),
|
||||
side,
|
||||
source: String::new(),
|
||||
mount_point: target::backing_dir(pair, side),
|
||||
options: vec![],
|
||||
owner_user: pair.owner_user.clone(),
|
||||
}
|
||||
});
|
||||
mount::backend_for(target::side_kind(pair, side)).unmount(&mount_target)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{CloudSide, MountKind};
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
fn sample_pair(local_kind: MountKind, cloud_kind: MountKind) -> DrivePair {
|
||||
DrivePair {
|
||||
id: "pair-1".into(),
|
||||
name: "Test".into(),
|
||||
enabled: true,
|
||||
owner_user: None,
|
||||
mount_point: "/media/smart-mount/pair-1".into(),
|
||||
local: LocalSide {
|
||||
kind: local_kind,
|
||||
address: crate::config::LocalAddress::Ip(Ipv4Addr::new(192, 168, 1, 10)),
|
||||
share: "share".into(),
|
||||
username: None,
|
||||
extra_options: vec!["vers=3.0".into()],
|
||||
},
|
||||
cloud: CloudSide {
|
||||
kind: cloud_kind,
|
||||
host_or_url: "https://cloud.example.com/dav".into(),
|
||||
share: "share".into(),
|
||||
username: None,
|
||||
extra_options: vec![],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_source_formats_smb_unc_path() {
|
||||
let pair = sample_pair(MountKind::Smb, MountKind::WebDav);
|
||||
let source =
|
||||
target::local_source(&pair.local, &GlobalSettings::default()).expect("local_source");
|
||||
assert_eq!(source, "//192.168.1.10/share");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_target_uses_backing_dir_not_visible_mount_point() {
|
||||
let pair = sample_pair(MountKind::Smb, MountKind::WebDav);
|
||||
let t =
|
||||
target::build_target(&pair, &GlobalSettings::default(), Side::Local).expect("build");
|
||||
assert_ne!(t.mount_point, pair.mount_point);
|
||||
assert_eq!(t.mount_point, target::backing_dir(&pair, Side::Local));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unmount_pair_noop_when_neither_side_mounted() {
|
||||
let pair = sample_pair(MountKind::Smb, MountKind::WebDav);
|
||||
let action = unmount_pair(&pair, &GlobalSettings::default())
|
||||
.await
|
||||
.expect("unmount");
|
||||
assert!(matches!(action, Action::NoOp));
|
||||
}
|
||||
}
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
use std::env;
|
||||
use std::process::Command;
|
||||
|
||||
/// Prüft, ob das Programm mit Root-/Administrator-Rechten ausgeführt wird.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `true` - Das Programm läuft mit erhöhten Rechten
|
||||
/// * `false` - Das Programm läuft mit normalen Benutzerrechten
|
||||
pub fn is_run_as_root() -> bool {
|
||||
#[cfg(any(target_os = "windows"))]
|
||||
{
|
||||
// Windows: Prüfen ob Admin-Rechte vorhanden
|
||||
Command::new("net")
|
||||
.args(&["session"])
|
||||
.output()
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows")))]
|
||||
{
|
||||
// Linux/Unix: Prüfen ob Root oder sudo
|
||||
unsafe { libc::geteuid() == 0 }
|
||||
}
|
||||
}
|
||||
/// Startet das Programm mit Root-/Administrator-Rechten neu.
|
||||
///
|
||||
/// Diese Funktion versucht das Programm mit erhöhten Rechten neu zu starten.
|
||||
///
|
||||
/// # Details
|
||||
///
|
||||
/// - Unter Linux wird das Programm mit `sudo` neu gestartet
|
||||
/// - Unter Windows wird der UAC-Dialog zur Rechteanfrage angezeigt
|
||||
pub fn run_as_root() {
|
||||
#[cfg(any(target_os = "windows"))]
|
||||
{
|
||||
let commandline_args: Vec<String> = env::args().collect();
|
||||
let program_path = commandline_args[0].clone();
|
||||
|
||||
Command::new("powershell")
|
||||
.args(&[
|
||||
"Start-Process",
|
||||
&program_path,
|
||||
"-ArgumentList",
|
||||
&commandline_args[1..].join(" "),
|
||||
"-Verb",
|
||||
"RunAs"
|
||||
])
|
||||
.exec();
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows")))]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
let commandline_args: Vec<String> = env::args().collect();
|
||||
let _output = Command::new("sudo")
|
||||
.args(&commandline_args)
|
||||
.exec(); // Bye bye never returns
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Cron-Fallback (`smart-mount service crontab`) für Systeme ohne (oder ohne genutzten)
|
||||
//! systemd.
|
||||
//!
|
||||
//! Die eigentliche systemd-Einrichtung passiert NICHT mehr zur Laufzeit über dieses Modul,
|
||||
//! sondern über die paketierten, statischen Unit-Dateien (siehe `packaging/systemd/` im
|
||||
//! Quellbaum) sowie die postinst/postrm-Skripte der .deb/.rpm/.pkg.tar.zst-Pakete - smart-mount
|
||||
//! richtet sich beim Installieren des Pakets automatisch als System-systemd-Dienst ein und
|
||||
//! entfernt sich beim Deinstallieren wieder. Dieses Modul bleibt für Systeme ohne systemd (oder
|
||||
//! zum bewussten Umgehen von systemd) als manueller Cron-Weg bestehen - immer systemweit
|
||||
//! (`/etc/cron.d/smart-mount`), da Mounten ohnehin immer Root-Rechte braucht (siehe
|
||||
//! [`crate::cli::require_root`]); eine persönliche Nutzer-Crontab liefe ins Leere.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
const CRON_D_PATH: &str = "/etc/cron.d/smart-mount";
|
||||
const CRON_BEGIN_MARKER: &str = "# BEGIN smart-mount managed block";
|
||||
const CRON_END_MARKER: &str = "# END smart-mount managed block";
|
||||
|
||||
fn binary_path() -> String {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.to_str().map(str::to_string))
|
||||
.unwrap_or_else(|| "/usr/bin/smart-mount".to_string())
|
||||
}
|
||||
|
||||
/// Ob `systemctl` auf diesem System vorhanden ist - rein informativ für `doctor`; die
|
||||
/// eigentliche systemd-Einrichtung läuft über die Paketierung (siehe Moduldoku).
|
||||
pub fn is_available() -> bool {
|
||||
crate::mount::binary_available("systemctl")
|
||||
}
|
||||
|
||||
/// Erzeugt die Crontab-Äquivalente zu den paketierten systemd-Units, für Systeme ohne
|
||||
/// (genutzten) systemd. `watch_interval_secs` ist derselbe Wert wie
|
||||
/// `settings.watch_interval_secs`.
|
||||
pub fn crontab_equivalent(watch_interval_secs: u64) -> String {
|
||||
let bin = binary_path();
|
||||
let schedule = cron_schedule_for_interval(watch_interval_secs);
|
||||
format!("@reboot {bin} mount --all\n{schedule} {bin} watch\n")
|
||||
}
|
||||
|
||||
/// Ergebnis von [`install_cron`].
|
||||
pub enum CronInstallOutcome {
|
||||
/// Systemweiter Eintrag geschrieben (`/etc/cron.d/smart-mount`).
|
||||
SystemFile(PathBuf),
|
||||
/// Kein Cron-Mechanismus auf diesem System gefunden - nichts geschrieben, der Aufrufer
|
||||
/// sollte stattdessen [`crontab_equivalent`] anzeigen.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// Richtet die periodische Ausführung direkt über `/etc/cron.d/smart-mount` ein - manueller
|
||||
/// Fallback für Systeme ohne (genutzten) systemd, sofern `/etc/cron.d` existiert - sonst
|
||||
/// [`CronInstallOutcome::Unavailable`] statt eines Fehlers, der Aufrufer zeigt dann
|
||||
/// [`crontab_equivalent`] zur manuellen Einrichtung. Erfordert Root (siehe Moduldoku) - der
|
||||
/// Aufrufer (`cli::service`) prüft das bereits vorab.
|
||||
pub fn install_cron(watch_interval_secs: u64) -> Result<CronInstallOutcome> {
|
||||
if !Path::new("/etc/cron.d").is_dir() {
|
||||
return Ok(CronInstallOutcome::Unavailable);
|
||||
}
|
||||
|
||||
let contents = managed_cron_block(watch_interval_secs);
|
||||
std::fs::write(CRON_D_PATH, &contents).map_err(|e| Error::io(CRON_D_PATH, e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(CRON_D_PATH, std::fs::Permissions::from_mode(0o644))
|
||||
.map_err(|e| Error::io(CRON_D_PATH, e))?;
|
||||
}
|
||||
Ok(CronInstallOutcome::SystemFile(PathBuf::from(CRON_D_PATH)))
|
||||
}
|
||||
|
||||
fn managed_cron_block(watch_interval_secs: u64) -> String {
|
||||
let bin = binary_path();
|
||||
let schedule = cron_schedule_for_interval(watch_interval_secs);
|
||||
// /etc/cron.d-Zeilen brauchen (anders als persönliche Crontabs) ein Nutzerfeld - root,
|
||||
// passend dazu, dass Mounts auch sonst immer als root laufen.
|
||||
format!(
|
||||
"{CRON_BEGIN_MARKER}\n@reboot root {bin} mount --all\n{schedule} root {bin} watch\n{CRON_END_MARKER}\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Ergebnis von [`uninstall_cron`].
|
||||
pub enum CronUninstallOutcome {
|
||||
/// Ein verwalteter Cron-Eintrag wurde gefunden und entfernt.
|
||||
Removed,
|
||||
/// Kein von smart-mount verwalteter Cron-Eintrag vorhanden - nichts zu tun.
|
||||
NotPresent,
|
||||
}
|
||||
|
||||
/// Gegenstück zu [`install_cron`]: entfernt einen zuvor über `install_cron` angelegten
|
||||
/// Cron-Eintrag wieder, sofern vorhanden.
|
||||
pub fn uninstall_cron() -> Result<CronUninstallOutcome> {
|
||||
let path = Path::new(CRON_D_PATH);
|
||||
if !path.exists() {
|
||||
return Ok(CronUninstallOutcome::NotPresent);
|
||||
}
|
||||
std::fs::remove_file(path).map_err(|e| Error::io(CRON_D_PATH, e))?;
|
||||
Ok(CronUninstallOutcome::Removed)
|
||||
}
|
||||
|
||||
/// Rechnet ein Sekunden-Intervall in einen `*/N`-artigen Cron-Ausdruck um. Crons Granularität
|
||||
/// ist Minuten (keine Sekunden) - es wird auf die nächste Minute gerundet, mindestens 1
|
||||
/// (Cron kann nicht häufiger als minütlich auslösen). Ab 60 Minuten wird auf Stunden
|
||||
/// umgestellt (`0 */N * * *`); Intervalle über 24h werden grob als "täglich um Mitternacht"
|
||||
/// angenähert, da ein reiner `*/N`-Ausdruck das nicht mehr sauber abbilden kann.
|
||||
fn cron_schedule_for_interval(interval_secs: u64) -> String {
|
||||
let minutes = ((interval_secs as f64 / 60.0).round() as u64).max(1);
|
||||
if minutes <= 59 {
|
||||
format!("*/{minutes} * * * *")
|
||||
} else {
|
||||
let hours = ((minutes as f64 / 60.0).round() as u64).max(1);
|
||||
if hours <= 23 {
|
||||
format!("0 */{hours} * * *")
|
||||
} else {
|
||||
"0 0 * * *".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn managed_cron_block_includes_root_user_field_and_both_lines() {
|
||||
let block = managed_cron_block(120);
|
||||
assert!(block.starts_with(CRON_BEGIN_MARKER));
|
||||
assert!(block.trim_end().ends_with(CRON_END_MARKER));
|
||||
assert!(block.contains("@reboot root "));
|
||||
assert!(block.contains("mount --all"));
|
||||
assert!(block.contains("*/2 * * * * root "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_watch_interval_maps_to_every_two_minutes() {
|
||||
assert_eq!(cron_schedule_for_interval(120), "*/2 * * * *");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounds_to_the_nearest_minute() {
|
||||
assert_eq!(cron_schedule_for_interval(90), "*/2 * * * *");
|
||||
assert_eq!(cron_schedule_for_interval(80), "*/1 * * * *");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_minute_intervals_clamp_to_one_minute() {
|
||||
assert_eq!(cron_schedule_for_interval(30), "*/1 * * * *");
|
||||
assert_eq!(cron_schedule_for_interval(1), "*/1 * * * *");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switches_to_hourly_expression_above_59_minutes() {
|
||||
assert_eq!(cron_schedule_for_interval(60 * 90), "0 */2 * * *");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn very_long_intervals_fall_back_to_daily_at_midnight() {
|
||||
assert_eq!(cron_schedule_for_interval(60 * 60 * 30), "0 0 * * *");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crontab_equivalent_includes_both_reboot_and_watch_lines() {
|
||||
let output = crontab_equivalent(120);
|
||||
assert!(output.contains("@reboot"));
|
||||
assert!(output.contains("mount --all"));
|
||||
assert!(output.contains("*/2 * * * *"));
|
||||
assert!(output.contains("watch"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user