diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e536c3f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.github +**/node_modules +frontend/dist +**/*.db +**/*.db-* +**/*.log +**/coverage* +.env +.env.* +release-files +artifacts +3m-ui-linux-* +3m-ui-bundle-* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a75147d..363aad3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main, 'agent/**'] + branches: [main, 'agent/**', 'feat/**'] pull_request: branches: [main] workflow_dispatch: @@ -27,6 +27,10 @@ jobs: run: go test ./... - name: Telegram race/security tests run: go test -race ./internal/telegram -run 'Test(Concurrent|UpdateOffset|HTMLSplit|SplitTelegramHTML|ParseUpdates|SetCommands)' + - name: Static release driver and initialization tests + run: go test -tags sqlite_modernc ./... + - name: Initialization race tests + run: go test -race -tags sqlite_modernc ./internal/bootstrap ./internal/config ./cmd/server - name: Go build run: go build ./... frontend: @@ -40,8 +44,8 @@ jobs: with: node-version: 22 cache: npm - cache-dependency-path: frontend/package.json - - run: npm install + cache-dependency-path: frontend/package-lock.json + - run: npm ci --no-audit --no-fund - run: npm run lint - run: npm run test:unit - run: npm run build @@ -53,6 +57,8 @@ jobs: - name: Validate POSIX shell scripts run: | set -eu - for script in scripts/install.sh scripts/update.sh scripts/uninstall.sh scripts/3m-ui.sh scripts/3m-ui; do + for script in scripts/install.sh scripts/update.sh scripts/uninstall.sh scripts/3m-ui.sh scripts/3m-ui scripts/download-mihomo.sh scripts/build-release-bundle.sh; do sh -n "$script" done + - name: Distribution and lifecycle regression tests + run: python3 -m unittest discover -s scripts/tests -v diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml new file mode 100644 index 0000000..33a9e3d --- /dev/null +++ b/.github/workflows/release-build.yml @@ -0,0 +1,116 @@ +name: Build release packages +on: + workflow_call: + inputs: + source_sha: + required: true + type: string + version: + required: true + type: string +permissions: + contents: read +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - goarch: amd64 + goamd64: v1 + artifact: linux-amd64 + - goarch: arm64 + artifact: linux-arm64 + runner: ubuntu-24.04-arm + - goarch: arm + goarm: '7' + artifact: linux-armv7 + - goarch: arm + goarm: '6' + artifact: linux-armv6 + - goarch: '386' + artifact: linux-386 + - goarch: riscv64 + artifact: linux-riscv64 + - goarch: loong64 + artifact: linux-loong64 + - goarch: ppc64le + artifact: linux-ppc64le + - goarch: s390x + artifact: linux-s390x + runs-on: ${{ matrix.runner || 'ubuntu-latest' }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.source_sha }} + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + - uses: actions/setup-go@v5 + with: + go-version: '1.25.x' + cache-dependency-path: backend/go.sum + - name: Verify distribution policy and checksum rejection + if: matrix.goarch == 'amd64' + run: PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s scripts/tests -p test_distribution.py -v + - name: Build and embed frontend + working-directory: frontend + run: | + npm ci --no-audit --no-fund + npm run build + rm -rf ../backend/cmd/server/web/dist + cp -r dist ../backend/cmd/server/web/dist + - name: Build static panel + working-directory: backend + env: + CGO_ENABLED: '0' + GOOS: linux + GOARCH: ${{ matrix.goarch }} + GOARM: ${{ matrix.goarm }} + GOAMD64: ${{ matrix.goamd64 }} + VERSION: ${{ inputs.version }} + GIT_COMMIT: ${{ inputs.source_sha }} + ARTIFACT: ${{ matrix.artifact }} + run: | + BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) + mkdir -p ../release-files + go build -tags sqlite_modernc -trimpath \ + -ldflags="-s -w -X main.version=$VERSION -X main.gitCommit=$GIT_COMMIT -X main.buildTime=$BUILD_TIME" \ + -o "../release-files/3m-ui-$ARTIFACT" ./cmd/server + # A dynamic interpreter makes the advertised static package invalid. + if readelf -l "../release-files/3m-ui-$ARTIFACT" | grep -q INTERP; then + echo 'Unexpected dynamically linked panel' >&2 + exit 1 + fi + - name: Archive advanced standalone binary + env: + ARTIFACT: ${{ matrix.artifact }} + run: tar -czf "release-files/3m-ui-$ARTIFACT.tar.gz" -C release-files "3m-ui-$ARTIFACT" + - name: Package complete panel and Mihomo bundle + if: matrix.goarch == 'amd64' || matrix.goarch == 'arm64' + env: + VERSION: ${{ inputs.version }} + REPOSITORY: ${{ github.repository }} + ARCH: ${{ matrix.goarch }} + ARTIFACT: ${{ matrix.artifact }} + run: sh scripts/build-release-bundle.sh "$ARCH" "release-files/3m-ui-$ARTIFACT" release-files + - name: Verify real core connections, restart and data restoration + if: matrix.goarch == 'amd64' || matrix.goarch == 'arm64' + env: + ARTIFACT: ${{ matrix.artifact }} + VERSION: ${{ inputs.version }} + run: | + mkdir -p smoke + tar -xzf "release-files/3m-ui-bundle-$ARTIFACT.tar.gz" -C smoke + smoke/3m-ui-bin --version 2>&1 | grep -F "$VERSION" + sudo mkdir -p /opt/3m-ui-release-smoke + sudo install -m 0755 smoke/mihomo /opt/3m-ui-release-smoke/mihomo + trap 'sudo rm -rf /opt/3m-ui-release-smoke' EXIT + python3 scripts/smoke-runtime.py "$PWD/smoke/3m-ui-bin" /opt/3m-ui-release-smoke/mihomo + - uses: actions/upload-artifact@v4 + with: + name: archive-${{ matrix.artifact }} + path: release-files/* + if-no-files-found: error diff --git a/.github/workflows/release-image.yml b/.github/workflows/release-image.yml new file mode 100644 index 0000000..27b167a --- /dev/null +++ b/.github/workflows/release-image.yml @@ -0,0 +1,151 @@ +name: Publish reviewed multiarch image +on: + workflow_call: + inputs: + source_sha: + required: true + type: string + version: + required: true + type: string + image_tag: + required: true + type: string + latest: + required: true + type: boolean +permissions: + contents: read + packages: write +jobs: + verify: + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.source_sha }} + - name: Build metadata + id: build-meta + run: echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + - uses: docker/setup-buildx-action@v3 + - uses: docker/build-push-action@v6 + with: + context: . + load: true + platforms: linux/${{ matrix.arch }} + tags: local/3m-ui:smoke + build-args: | + VERSION=${{ inputs.version }} + GIT_COMMIT=${{ inputs.source_sha }} + BUILD_TIME=${{ steps.build-meta.outputs.build_time }} + REPOSITORY=${{ github.repository }} + cache-from: type=gha,scope=image-${{ matrix.arch }} + cache-to: type=gha,scope=image-${{ matrix.arch }},mode=max + - name: Verify image initialization, permissions and persistent restart + run: | + set -euo pipefail + docker run -d --name 3m-ui-smoke --init --cap-drop ALL \ + --security-opt no-new-privileges local/3m-ui:smoke + trap 'docker rm -fv 3m-ui-smoke >/dev/null' EXIT + test "$(docker inspect --format '{{.Config.User}}' 3m-ui-smoke)" = 10001:10001 + docker exec 3m-ui-smoke /usr/local/lib/3m-ui/mihomo -v + wait_healthy() { + for attempt in $(seq 1 60); do + if docker exec 3m-ui-smoke /usr/local/bin/3m-ui healthcheck >/dev/null 2>&1; then return; fi + sleep 1 + done + docker logs 3m-ui-smoke 2>&1 | sed '/^Initial password: /d' >&2 + return 1 + } + wait_healthy + docker exec 3m-ui-smoke pidof mihomo >/dev/null + docker exec 3m-ui-smoke sha256sum /etc/3m-ui/config.yaml > config-before.sha256 + docker restart 3m-ui-smoke + wait_healthy + docker exec 3m-ui-smoke sha256sum /etc/3m-ui/config.yaml > config-after.sha256 + cmp config-before.sha256 config-after.sha256 + test "$(docker logs 3m-ui-smoke 2>&1 | grep -c '^Initial password: ')" = 1 + - name: Save the verified image without rebuilding it + env: + ARCH: ${{ matrix.arch }} + run: docker save local/3m-ui:smoke -o "$RUNNER_TEMP/3m-ui-$ARCH.tar" + - uses: actions/upload-artifact@v4 + with: + name: container-${{ matrix.arch }} + path: ${{ runner.temp }}/3m-ui-${{ matrix.arch }}.tar + compression-level: 0 + retention-days: 1 + image: + needs: verify + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.source_sha }} + - name: Image metadata + id: meta + env: + REPOSITORY: ${{ github.repository }} + IMAGE_TAG: ${{ inputs.image_tag }} + IS_LATEST: ${{ inputs.latest }} + run: | + IMAGE="ghcr.io/${REPOSITORY,,}" + echo "image=$IMAGE" >> "$GITHUB_OUTPUT" + { + echo 'tags<> "$GITHUB_OUTPUT" + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Refuse replacement of a formal version image + if: inputs.image_tag != 'pre' + env: + IMAGE: ${{ steps.meta.outputs.image }} + IMAGE_TAG: ${{ inputs.image_tag }} + run: | + if docker buildx imagetools inspect "$IMAGE:$IMAGE_TAG" >/dev/null 2>registry-error; then + echo "Image tag already exists: $IMAGE:$IMAGE_TAG. Publish a new version." >&2 + exit 1 + fi + # Authentication/network errors must not be mistaken for a missing tag. + if ! grep -qiE 'not found|manifest unknown' registry-error; then + cat registry-error >&2 + exit 1 + fi + - uses: actions/download-artifact@v4 + with: + pattern: container-* + path: verified-images + merge-multiple: true + - name: Publish the exact images that passed verification + env: + IMAGE: ${{ steps.meta.outputs.image }} + IMAGE_TAG: ${{ inputs.image_tag }} + IS_LATEST: ${{ inputs.latest }} + run: | + set -euo pipefail + for ARCH in amd64 arm64; do + docker load --input "verified-images/3m-ui-$ARCH.tar" + STAGING="$IMAGE:build-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT-$ARCH" + docker tag local/3m-ui:smoke "$STAGING" + docker push "$STAGING" + docker image inspect "$STAGING" --format '{{index .RepoDigests 0}}' >> image-digests + done + mapfile -t DIGESTS < image-digests + TAGS=(--tag "$IMAGE:$IMAGE_TAG") + if [ "$IS_LATEST" = true ]; then TAGS+=(--tag "$IMAGE:latest"); fi + docker buildx imagetools create "${TAGS[@]}" "${DIGESTS[@]}" diff --git a/.github/workflows/release-pre.yml b/.github/workflows/release-pre.yml index cfbe379..54c2261 100644 --- a/.github/workflows/release-pre.yml +++ b/.github/workflows/release-pre.yml @@ -1,212 +1,88 @@ -name: Pre-release · rolling (pre) -# Pure-Go static binaries (CGO_ENABLED=0 + modernc sqlite) for maximum Linux portability: -# works on glibc and musl (Alpine), old and new distros, without system libsqlite3. +name: Development release (rolling pre) on: workflow_dispatch: - inputs: - note: - description: Optional note for this pre build - required: false - type: string permissions: - contents: write - id-token: write # required for cosign keyless OIDC signing - + contents: read concurrency: group: release-pre-rolling - cancel-in-progress: true - + cancel-in-progress: false jobs: + metadata: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.meta.outputs.version }} + steps: + - name: Identify the explicit development build + id: meta + run: echo "version=pre-${GITHUB_SHA:0:12}" >> "$GITHUB_OUTPUT" build: - strategy: - fail-fast: false - matrix: - include: - # x86_64 — GOAMD64=v1 for broadest CPU compatibility (pre-AVX hosts) - - goarch: amd64 - goamd64: v1 - artifact: linux-amd64 - - goarch: arm64 - artifact: linux-arm64 - - goarch: arm - goarm: '7' - artifact: linux-armv7 - - goarch: arm - goarm: '6' - artifact: linux-armv6 - - goarch: '386' - artifact: linux-386 - - goarch: riscv64 - artifact: linux-riscv64 - - goarch: loong64 - artifact: linux-loong64 - - goarch: ppc64le - artifact: linux-ppc64le - - goarch: s390x - artifact: linux-s390x - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - cache-dependency-path: frontend/package.json - - uses: actions/setup-go@v5 - with: - go-version: '1.25.x' - - name: Build Ant Design frontend - working-directory: frontend - run: npm install && npm run build - - name: Embed frontend - run: | - rm -rf backend/cmd/server/web/dist - cp -r frontend/dist backend/cmd/server/web/dist - - name: Build static backend (${{ matrix.artifact }}) - working-directory: backend - env: - CGO_ENABLED: '0' - GOOS: linux - GOARCH: ${{ matrix.goarch }} - GOARM: ${{ matrix.goarm }} - GOAMD64: ${{ matrix.goamd64 }} - run: | - go build -tags sqlite_modernc -trimpath -ldflags='-s -w' \ - -o "../3m-ui-${{ matrix.artifact }}" ./cmd/server - file "../3m-ui-${{ matrix.artifact }}" || true - if command -v ldd >/dev/null 2>&1; then - if ldd "../3m-ui-${{ matrix.artifact }}" 2>&1 | grep -qiE 'not a dynamic|statically linked'; then - echo "OK: static binary" - else - echo "WARN: binary may be dynamically linked:" - ldd "../3m-ui-${{ matrix.artifact }}" || true - fi - fi - - name: Archive - run: tar -czf "3m-ui-${{ matrix.artifact }}.tar.gz" "3m-ui-${{ matrix.artifact }}" - - uses: actions/upload-artifact@v4 - with: - name: archive-${{ matrix.artifact }} - path: | - 3m-ui-${{ matrix.artifact }} - 3m-ui-${{ matrix.artifact }}.tar.gz - + needs: metadata + uses: ./.github/workflows/release-build.yml + with: + source_sha: ${{ github.sha }} + version: ${{ needs.metadata.outputs.version }} release: - needs: build + needs: [metadata, build] runs-on: ubuntu-latest + permissions: + contents: write + id-token: write steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} - uses: actions/download-artifact@v4 with: - path: artifacts + path: release-files merge-multiple: true - - name: Flatten artifacts & generate SHA256SUMS + - name: Collect and checksum all assets + env: + VERSION: ${{ needs.metadata.outputs.version }} + REPOSITORY: ${{ github.repository }} run: | - set -euo pipefail - mkdir -p release-files - # download-artifact may nest by job; collect every binary/tarball - find artifacts -type f \( -name '3m-ui-linux-*' -o -name 'SHA256SUMS' \) -print0 | - while IFS= read -r -d '' f; do - cp -a "$f" "release-files/$(basename "$f")" - done - # Ship management scripts with the release (install.sh falls back to main if missing) - if [ -d scripts ]; then - for s in install.sh update.sh uninstall.sh 3m-ui.sh 3m-ui; do - [ -f "scripts/$s" ] && cp -a "scripts/$s" "release-files/$s" - done - fi + cp scripts/{install.sh,update.sh,uninstall.sh,3m-ui.sh,3m-ui} release-files/ + cp docker-compose*.yml release-files/ + cp distribution/mihomo.env release-files/ + sed -i "s#ghcr.io/kazeyukiro/3m-ui#ghcr.io/${REPOSITORY,,}#g" release-files/docker-compose*.yml + sed -i "s#kazeyukiro/3m-ui#${REPOSITORY}#g" release-files/install.sh + printf '%s\n' "$VERSION" > release-files/VERSION + printf '%s\n' "$REPOSITORY" > release-files/REPOSITORY cd release-files - # Hash with bare filenames (no ./ prefix) so installers can match assets - : > SHA256SUMS - for f in $(ls -1 | sort); do - [ -f "$f" ] || continue - [ "$f" = SHA256SUMS ] && continue - sha256sum "$f" >> SHA256SUMS - done - echo "=== SHA256SUMS ===" - cat SHA256SUMS - ls -la - - name: Resolve tag - id: meta + sha256sum * > SHA256SUMS + - uses: sigstore/cosign-installer@v3 + - name: Sign checksums using this development workflow identity + working-directory: release-files run: | - set -euo pipefail - TAG=pre - echo "tag=${TAG}" >> "$GITHUB_OUTPUT" - echo "sha=${GITHUB_SHA}" >> "$GITHUB_OUTPUT" - echo "prerelease=true" >> "$GITHUB_OUTPUT" - echo "make_latest=false" >> "$GITHUB_OUTPUT" - echo "release_name=Pre-release (rolling)" >> "$GITHUB_OUTPUT" - echo "Resolved rolling pre @ ${GITHUB_SHA}" - - name: Install cosign - uses: sigstore/cosign-installer@v3 - - name: Sign SHA256SUMS (keyless OIDC) + cosign sign-blob --yes --output-certificate SHA256SUMS.pem \ + --output-signature SHA256SUMS.sig SHA256SUMS + - name: Move the explicitly mutable development tag run: | - set -euo pipefail - cd release-files - # Keyless signing: uses the GitHub Actions OIDC token as identity. - # No long-lived secret is stored on GitHub. The resulting certificate - # binds the signature to this workflow + tag ref, so installers can - # verify authenticity with cosign verify-blob. - cosign sign-blob --yes \ - --output-certificate SHA256SUMS.pem \ - --output-signature SHA256SUMS.sig \ - SHA256SUMS - echo "=== Signature artifacts ===" - ls -la SHA256SUMS.pem SHA256SUMS.sig - - name: Point rolling tag at this commit - run: | - set -euo pipefail - git fetch --tags origin 2>/dev/null || true - git tag -f pre "${GITHUB_SHA}" + git tag -f pre "$GITHUB_SHA" git push -f origin refs/tags/pre - - uses: softprops/action-gh-release@v2 + - name: Publish explicit development channel + uses: softprops/action-gh-release@v2 with: - tag_name: ${{ steps.meta.outputs.tag }} + tag_name: pre target_commitish: ${{ github.sha }} - name: ${{ steps.meta.outputs.release_name }} - generate_release_notes: true - prerelease: ${{ steps.meta.outputs.prerelease }} - make_latest: ${{ steps.meta.outputs.make_latest }} - # Overwrite assets on the same tag instead of accumulating duplicates + name: Development build ${{ needs.metadata.outputs.version }} + prerelease: true + make_latest: false body: | - ## Packages - - **Channel: rolling Pre-release** (tag `pre`). - - Triggered via Actions → Pre-release · rolling (pre). - ${{ inputs.note }} - - Official frontend: **Ant Design**. - - All binaries are **pure-Go static** (`CGO_ENABLED=0` + `modernc` SQLite): - run on glibc *and* musl (Alpine), without system `libsqlite3`. - - Integrity: download `SHA256SUMS` and verify with `sha256sum -c SHA256SUMS`. - - **Authenticity (cosign keyless):** `SHA256SUMS.pem` + `SHA256SUMS.sig` - are produced via Sigstore keyless OIDC. Verify end-to-end: - ```bash - cosign verify-blob \\ - --certificate SHA256SUMS.pem \\ - --signature SHA256SUMS.sig \\ - --certificate-identity 'https://github.com/kazeyukiro/3m-ui/.github/workflows/release.yml@refs/tags/' \\ - --certificate-oidc-issuer https://token.actions.githubusercontent.com \\ - SHA256SUMS - ``` - (replace `` with this release tag) - - | Artifact | Architecture | - |---|---| - | `3m-ui-linux-amd64` | x86_64 (GOAMD64=v1) | - | `3m-ui-linux-arm64` | aarch64 | - | `3m-ui-linux-armv7` | ARMv7 | - | `3m-ui-linux-armv6` | ARMv6 (e.g. older Pi) | - | `3m-ui-linux-386` | i386 / i686 | - | `3m-ui-linux-riscv64` | RISC-V 64 | - | `3m-ui-linux-loong64` | LoongArch64 | - | `3m-ui-linux-ppc64le` | POWER little-endian | - | `3m-ui-linux-s390x` | IBM Z | - - Each arch is also published as `.tar.gz`. - files: | - release-files/* + Rolling development build, selected explicitly with THREE_M_UI_CHANNEL=pre. + This tag and its assets may change; ordinary installs use stable releases. + Panel version: ${{ needs.metadata.outputs.version }} + Commit: ${{ github.sha }} + Complete amd64/arm64 bundles include the pinned Mihomo core. + Docker image: ghcr.io//:pre. + files: release-files/* + image: + needs: [metadata, release] + permissions: + contents: read + packages: write + uses: ./.github/workflows/release-image.yml + with: + source_sha: ${{ github.sha }} + version: ${{ needs.metadata.outputs.version }} + image_tag: pre + latest: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fe38a6d..e9b81a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,4 @@ -name: Release · Ant Design -# Pure-Go static binaries (CGO_ENABLED=0 + modernc sqlite) for maximum Linux portability: -# works on glibc and musl (Alpine), old and new distros, without system libsqlite3. +name: Release stable or release candidate on: push: tags: @@ -8,212 +6,114 @@ on: workflow_dispatch: inputs: tag: - description: Release tag (e.g. v1.0.2). Required for manual runs. + description: Existing SemVer tag to build (v1.2.3 or v1.3.0-rc.1) required: true type: string permissions: - contents: write - id-token: write # required for cosign keyless OIDC signing - + contents: read concurrency: - group: release-formal-${{ github.ref }} + group: release-formal-${{ inputs.tag || github.ref_name }} cancel-in-progress: false - jobs: - build: - strategy: - fail-fast: false - matrix: - include: - # x86_64 — GOAMD64=v1 for broadest CPU compatibility (pre-AVX hosts) - - goarch: amd64 - goamd64: v1 - artifact: linux-amd64 - - goarch: arm64 - artifact: linux-arm64 - - goarch: arm - goarm: '7' - artifact: linux-armv7 - - goarch: arm - goarm: '6' - artifact: linux-armv6 - - goarch: '386' - artifact: linux-386 - - goarch: riscv64 - artifact: linux-riscv64 - - goarch: loong64 - artifact: linux-loong64 - - goarch: ppc64le - artifact: linux-ppc64le - - goarch: s390x - artifact: linux-s390x + metadata: runs-on: ubuntu-latest + outputs: + tag: ${{ steps.meta.outputs.tag }} + sha: ${{ steps.meta.outputs.sha }} + prerelease: ${{ steps.meta.outputs.prerelease }} + latest: ${{ steps.meta.outputs.latest }} + image_tag: ${{ steps.meta.outputs.image_tag }} steps: + # Manual runs must build the requested tag, not the branch selected in UI. - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 with: - node-version: 22 - cache: npm - cache-dependency-path: frontend/package.json - - uses: actions/setup-go@v5 - with: - go-version: '1.25.x' - - name: Build Ant Design frontend - working-directory: frontend - run: npm install && npm run build - - name: Embed frontend - run: | - rm -rf backend/cmd/server/web/dist - cp -r frontend/dist backend/cmd/server/web/dist - - name: Build static backend (${{ matrix.artifact }}) - working-directory: backend - env: - CGO_ENABLED: '0' - GOOS: linux - GOARCH: ${{ matrix.goarch }} - GOARM: ${{ matrix.goarm }} - GOAMD64: ${{ matrix.goamd64 }} - run: | - go build -tags sqlite_modernc -trimpath -ldflags='-s -w' \ - -o "../3m-ui-${{ matrix.artifact }}" ./cmd/server - file "../3m-ui-${{ matrix.artifact }}" || true - if command -v ldd >/dev/null 2>&1; then - if ldd "../3m-ui-${{ matrix.artifact }}" 2>&1 | grep -qiE 'not a dynamic|statically linked'; then - echo "OK: static binary" - else - echo "WARN: binary may be dynamically linked:" - ldd "../3m-ui-${{ matrix.artifact }}" || true - fi - fi - - name: Archive - run: tar -czf "3m-ui-${{ matrix.artifact }}.tar.gz" "3m-ui-${{ matrix.artifact }}" - - uses: actions/upload-artifact@v4 - with: - name: archive-${{ matrix.artifact }} - path: | - 3m-ui-${{ matrix.artifact }} - 3m-ui-${{ matrix.artifact }}.tar.gz - - release: - needs: build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 - with: - path: artifacts - merge-multiple: true - - name: Flatten artifacts & generate SHA256SUMS - run: | - set -euo pipefail - mkdir -p release-files - # download-artifact may nest by job; collect every binary/tarball - find artifacts -type f \( -name '3m-ui-linux-*' -o -name 'SHA256SUMS' \) -print0 | - while IFS= read -r -d '' f; do - cp -a "$f" "release-files/$(basename "$f")" - done - # Ship management scripts with the release (install.sh falls back to main if missing) - if [ -d scripts ]; then - for s in install.sh update.sh uninstall.sh 3m-ui.sh 3m-ui; do - [ -f "scripts/$s" ] && cp -a "scripts/$s" "release-files/$s" - done - fi - cd release-files - # Hash with bare filenames (no ./ prefix) so installers can match assets - : > SHA256SUMS - for f in $(ls -1 | sort); do - [ -f "$f" ] || continue - [ "$f" = SHA256SUMS ] && continue - sha256sum "$f" >> SHA256SUMS - done - echo "=== SHA256SUMS ===" - cat SHA256SUMS - ls -la - - name: Resolve tag + ref: refs/tags/${{ inputs.tag || github.ref_name }} + - name: Resolve exact tag and channel id: meta + env: + TAG: ${{ inputs.tag || github.ref_name }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - set -euo pipefail - if [ -n "${{ inputs.tag }}" ]; then - TAG="${{ inputs.tag }}" - elif [ "${{ github.ref_type }}" = "tag" ]; then - TAG="${{ github.ref_name }}" - else - echo "Formal release requires a v* tag or workflow_dispatch tag input" >&2 + python3 scripts/release-metadata.py "$TAG" + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + if gh release view "$TAG" >/dev/null 2>release-error; then + echo "Release $TAG already exists. Published versions cannot be replaced." >&2 exit 1 fi - case "$TAG" in - v*) ;; - *) echo "Formal release tag must start with v (got: $TAG)" >&2; exit 1 ;; - esac - echo "tag=${TAG}" >> "$GITHUB_OUTPUT" - echo "sha=${GITHUB_SHA}" >> "$GITHUB_OUTPUT" - echo "prerelease=false" >> "$GITHUB_OUTPUT" - echo "make_latest=true" >> "$GITHUB_OUTPUT" - echo "release_name=${TAG}" >> "$GITHUB_OUTPUT" - echo "Resolved formal release: ${TAG} @ ${GITHUB_SHA}" - - name: Install cosign - uses: sigstore/cosign-installer@v3 - - name: Sign SHA256SUMS (keyless OIDC) - run: | - set -euo pipefail - cd release-files - # Keyless signing: uses the GitHub Actions OIDC token as identity. - # No long-lived secret is stored on GitHub. The resulting certificate - # binds the signature to this workflow + tag ref, so installers can - # verify authenticity with cosign verify-blob. - cosign sign-blob --yes \ - --output-certificate SHA256SUMS.pem \ - --output-signature SHA256SUMS.sig \ - SHA256SUMS - echo "=== Signature artifacts ===" - ls -la SHA256SUMS.pem SHA256SUMS.sig - - uses: softprops/action-gh-release@v2 + if ! grep -qiE 'release not found|HTTP 404' release-error; then + cat release-error >&2 + exit 1 + fi + build: + needs: metadata + uses: ./.github/workflows/release-build.yml + with: + source_sha: ${{ needs.metadata.outputs.sha }} + version: ${{ needs.metadata.outputs.tag }} + release: + needs: [metadata, build] + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@v4 with: - tag_name: ${{ steps.meta.outputs.tag }} - target_commitish: ${{ github.sha }} - name: ${{ steps.meta.outputs.release_name }} - generate_release_notes: true - prerelease: ${{ steps.meta.outputs.prerelease }} - make_latest: ${{ steps.meta.outputs.make_latest }} - # Overwrite assets on the same tag instead of accumulating duplicates - body: | - ## Packages - - **Tag:** `${{ steps.meta.outputs.tag }}` - - See release notes for the changelog. - - Official frontend: **Ant Design**. - - All binaries are **pure-Go static** (`CGO_ENABLED=0` + `modernc` SQLite): - run on glibc *and* musl (Alpine), without system `libsqlite3`. - - Integrity: download `SHA256SUMS` and verify with `sha256sum -c SHA256SUMS`. - - **Authenticity (cosign keyless):** `SHA256SUMS.pem` + `SHA256SUMS.sig` - are produced via Sigstore keyless OIDC. Verify end-to-end: - ```bash - cosign verify-blob \\ - --certificate SHA256SUMS.pem \\ - --signature SHA256SUMS.sig \\ - --certificate-identity 'https://github.com/kazeyukiro/3m-ui/.github/workflows/release.yml@refs/tags/' \\ - --certificate-oidc-issuer https://token.actions.githubusercontent.com \\ - SHA256SUMS - ``` - (replace `` with this release tag) - - | Artifact | Architecture | - |---|---| - | `3m-ui-linux-amd64` | x86_64 (GOAMD64=v1) | - | `3m-ui-linux-arm64` | aarch64 | - | `3m-ui-linux-armv7` | ARMv7 | - | `3m-ui-linux-armv6` | ARMv6 (e.g. older Pi) | - | `3m-ui-linux-386` | i386 / i686 | - | `3m-ui-linux-riscv64` | RISC-V 64 | - | `3m-ui-linux-loong64` | LoongArch64 | - | `3m-ui-linux-ppc64le` | POWER little-endian | - | `3m-ui-linux-s390x` | IBM Z | - - Each arch is also published as `.tar.gz`. - files: | - release-files/* + ref: ${{ needs.metadata.outputs.sha }} + - uses: actions/download-artifact@v4 + with: + path: release-files + merge-multiple: true + - name: Collect scripts and checksum every public asset + env: + VERSION: ${{ needs.metadata.outputs.tag }} + REPOSITORY: ${{ github.repository }} + run: | + cp scripts/{install.sh,update.sh,uninstall.sh,3m-ui.sh,3m-ui} release-files/ + cp docker-compose*.yml release-files/ + cp distribution/mihomo.env release-files/ + sed -i "s#ghcr.io/kazeyukiro/3m-ui#ghcr.io/${REPOSITORY,,}#g" release-files/docker-compose*.yml + sed -i "s#kazeyukiro/3m-ui#${REPOSITORY}#g" release-files/install.sh + printf '%s\n' "$VERSION" > release-files/VERSION + printf '%s\n' "$REPOSITORY" > release-files/REPOSITORY + cd release-files + sha256sum * > SHA256SUMS + - uses: sigstore/cosign-installer@v3 + - name: Sign checksums using this workflow identity + working-directory: release-files + run: | + cosign sign-blob --yes --output-certificate SHA256SUMS.pem \ + --output-signature SHA256SUMS.sig SHA256SUMS + - name: Publish once without overwriting assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.metadata.outputs.tag }} + PRERELEASE: ${{ needs.metadata.outputs.prerelease }} + LATEST: ${{ needs.metadata.outputs.latest }} + run: | + cat > release-notes.md <<'NOTES' + Complete Linux installation bundles are provided for amd64 and arm64. + Each bundle includes the panel, its pinned Mihomo core and lifecycle scripts. + Other architectures are standalone panel builds for advanced installations. + Verify downloads against SHA256SUMS. SHA256SUMS.pem and SHA256SUMS.sig + provide Sigstore keyless verification for this release workflow. + Docker images are published to ghcr.io//:. + The latest image and default installer follow stable releases only. + NOTES + # Reserve a new release; no --clobber or action asset overwrite behavior. + # A failed upload leaves a draft that maintainers must inspect explicitly. + gh release create "$TAG" --verify-tag --draft --title "$TAG" \ + --notes-file release-notes.md --generate-notes + gh release upload "$TAG" release-files/* + gh release edit "$TAG" --draft=false --prerelease="$PRERELEASE" --latest="$LATEST" + image: + needs: [metadata, release] + permissions: + contents: read + packages: write + uses: ./.github/workflows/release-image.yml + with: + source_sha: ${{ needs.metadata.outputs.sha }} + version: ${{ needs.metadata.outputs.tag }} + image_tag: ${{ needs.metadata.outputs.image_tag }} + latest: ${{ needs.metadata.outputs.latest == 'true' }} diff --git a/.gitignore b/.gitignore index 23a0a5f..76a9eb6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ node_modules/ *.log frontend-variants/ + +# Python helper bytecode +__pycache__/ diff --git a/Dockerfile b/Dockerfile index 81a190f..56edbc1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,45 +1,62 @@ -# Multi-stage build: pure-Go static 3m-ui binary with embedded Ant Design frontend. -# Compatible with glibc and musl hosts (scratch/alpine/distroless). - -# TODO(supply-chain, H-3): pin base images to immutable digests for reproducible -# builds. Floating tags (node:22-alpine, golang:1.25-alpine, alpine:3.21) are -# mutable — only @sha256: guarantees byte-identical rebuilds. Compute -# digests with `docker pull && docker inspect --format='{{.RepoDigests}}' ` -# and replace each `FROM` line below with `@sha256:`. -FROM node:22-alpine AS frontend +# Build frontend and Go on the builder's native architecture; only the final +# runtime stage needs emulation when producing the other supported platform. +FROM --platform=$BUILDPLATFORM node:22-alpine AS frontend WORKDIR /src/frontend -COPY frontend/package.json frontend/package-lock.json* ./ -RUN npm install --no-audit --no-fund +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci --no-audit --no-fund COPY frontend/ ./ RUN npm run build -FROM golang:1.25-alpine AS backend -WORKDIR /src -RUN apk add --no-cache git ca-certificates -COPY backend/go.mod backend/go.sum ./backend/ +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS backend +ARG TARGETOS +ARG TARGETARCH +ARG VERSION=dev +ARG GIT_COMMIT=unknown +ARG BUILD_TIME=unknown WORKDIR /src/backend +RUN apk add --no-cache ca-certificates +COPY backend/go.mod backend/go.sum ./ RUN go mod download COPY backend/ ./ COPY --from=frontend /src/frontend/dist ./cmd/server/web/dist -ENV CGO_ENABLED=0 -RUN go build -tags sqlite_modernc -trimpath -ldflags='-s -w' -o /out/3m-ui ./cmd/server +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH GOAMD64=v1 \ + go build -tags sqlite_modernc -trimpath \ + -ldflags="-s -w -X main.version=$VERSION -X main.gitCommit=$GIT_COMMIT -X main.buildTime=$BUILD_TIME" \ + -o /out/3m-ui ./cmd/server + +FROM --platform=$BUILDPLATFORM alpine:3.21 AS mihomo +ARG TARGETARCH +RUN apk add --no-cache ca-certificates curl +COPY distribution/mihomo.env /src/distribution/mihomo.env +COPY scripts/download-mihomo.sh /src/scripts/download-mihomo.sh +RUN sh /src/scripts/download-mihomo.sh "$TARGETARCH" /out FROM alpine:3.21 -RUN apk add --no-cache ca-certificates tzdata -# Run as a non-root user (H-2 / C-4). The panel binary does not need root in -# container mode. Mihomo TUN mode requires CAP_NET_ADMIN and is expected to run -# on the host (or in a privileged sidecar) — not inside this panel container. -RUN addgroup -S -g 10001 3m-ui && adduser -S -D -H -u 10001 -G 3m-ui 3m-ui -WORKDIR /app +ARG VERSION=dev +ARG GIT_COMMIT=unknown +ARG REPOSITORY=kazeyukiro/3m-ui +LABEL org.opencontainers.image.title="3m-ui" \ + org.opencontainers.image.source="https://github.com/${REPOSITORY}" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.revision="${GIT_COMMIT}" +RUN apk add --no-cache ca-certificates tzdata && \ + addgroup -S -g 10001 3m-ui && adduser -S -D -H -u 10001 -G 3m-ui 3m-ui && \ + mkdir -p /etc/3m-ui /var/lib/3m-ui /var/log/3m-ui /usr/local/lib/3m-ui && \ + chown -R 10001:10001 /etc/3m-ui /var/lib/3m-ui /var/log/3m-ui COPY --from=backend /out/3m-ui /usr/local/bin/3m-ui -# Pre-create the volume mount points with correct ownership so named volumes -# (docker-compose) inherit UID 10001 on first creation. Bind-mounted host dirs -# must also be `chown 10001:10001` on the host by the operator. -RUN mkdir -p /etc/3m-ui /var/lib/3m-ui /var/log/3m-ui && \ - chown -R 3m-ui:3m-ui /app /usr/local/bin/3m-ui /etc/3m-ui /var/lib/3m-ui /var/log/3m-ui -# Default paths match the installer layout. -ENV THREE_M_UI_CONFIG=/etc/3m-ui/config.yaml +COPY --from=mihomo /out/mihomo /usr/local/lib/3m-ui/mihomo +COPY --from=mihomo /out/MIHOMO_VERSION /usr/local/lib/3m-ui/MIHOMO_VERSION +COPY distribution/MIHOMO_LICENSE /usr/local/share/licenses/mihomo/LICENSE +COPY LICENSE /usr/local/share/licenses/3m-ui/LICENSE +RUN printf 'Mihomo source and build scripts: https://github.com/MetaCubeX/mihomo/tree/%s\n' \ + "$(cat /usr/local/lib/3m-ui/MIHOMO_VERSION)" > /usr/local/share/licenses/mihomo/SOURCE +# Immutable image files remain root-owned. Upgrade the reviewed panel/core pair +# by replacing the image; only configuration, state and logs are writable. +ENV THREE_M_UI_CONFIG=/etc/3m-ui/config.yaml \ + THREE_M_UI_CONTAINER=1 VOLUME ["/etc/3m-ui", "/var/lib/3m-ui", "/var/log/3m-ui"] EXPOSE 8080 USER 10001:10001 +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD ["/usr/local/bin/3m-ui", "healthcheck"] ENTRYPOINT ["/usr/local/bin/3m-ui"] diff --git a/README.md b/README.md index 3f06aee..8086d9c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ **Mihomo 服务端 Web 管理面板** -> **测试通道:** 使用 GitHub Actions「Pre-release · rolling (pre)」手动发布滚动预发布(标签 `pre`)。正式版打 `v*` 标签发布。安装/更新可用 `3m-ui channel pre|stable` 切换。 +> **稳定版优先:** 默认安装和更新只使用正式 Release。预发布需要主动选择。完整安装包及 Docker 镜像包含固定版本的 Mihomo;详见 [安装、升级与恢复](docs/installation.md)。 轻量、自托管,用于在 Linux 上管理 [Mihomo](https://github.com/MetaCubeX/mihomo) Listener、用户、订阅与运行状态。 @@ -31,30 +31,30 @@ | **运维** | 核心启停/更新、日志、仪表盘资源占用、Geo 文件、面板 SSL/ACME、备份恢复 | | **Telegram** | 告警与管理命令(需 Token + Chat ID) | | **多机** | 登记远程面板、健康检查、同步节点镜像、合并订阅、可选推送节点(默认远程禁用) | -| **安全** | JWT、首登改密、凭据加密、CORS;安装后请立即修改默认管理员密码 | +| **安全** | JWT、首登改密、凭据加密、CORS;随机初始密码、首登改密 | 前端:**React + Ant Design**(`frontend/`),构建后嵌入单一 Go 二进制。 发布产物:**纯静态链接**(`CGO_ENABLED=0` + modernc SQLite),无需系统 `libsqlite3`,兼容 glibc / musl。 -架构:`linux/amd64` · `arm64` · `armv7` · `armv6` · `386` · `riscv64` · `loong64` · `ppc64le` · `s390x` +完整安装包 / Docker:`linux/amd64` · `arm64`。高级独立面板二进制另外支持 `armv7` · `armv6` · `386` · `riscv64` · `loong64` · `ppc64le` · `s390x`,需要自行准备兼容内核。 --- ## 快速安装 ```bash -curl -fsSL https://raw.githubusercontent.com/kazeyukiro/3m-ui/main/scripts/install.sh | bash +curl -fsSL https://raw.githubusercontent.com/kazeyukiro/3m-ui/main/scripts/install.sh | sudo sh ``` -生产环境请先审查脚本。非交互安装可设置 `THREE_M_UI_NONINTERACTIVE=1`(结果写入 `/etc/3m-ui/install-result.env`)。 +完整安装支持 Linux amd64 / arm64,自动安装面板、配套 Mihomo 和系统服务。首次配置和管理员密码由程序统一生成;配置已存在时保持原值。其他架构可使用独立二进制和自行准备的内核,见 [详细安装说明](docs/installation.md)。 安装完成后: ```text 面板地址 http://SERVER_IP:8080/ 默认账号 admin -默认密码 admin ← 首次登录必须修改 +初始密码 安装时随机生成并显示一次,首次登录必须修改 管理命令 3m-ui ``` @@ -64,18 +64,30 @@ curl -fsSL https://raw.githubusercontent.com/kazeyukiro/3m-ui/main/scripts/insta # 推荐(已安装后) sudo 3m-ui update # 指定版本: sudo 3m-ui update v1.0.0 -# 或: -curl -fsSL https://raw.githubusercontent.com/kazeyukiro/3m-ui/main/scripts/update.sh | sudo bash +# 从旧安装脚本迁移时,先重新执行上面的最新安装命令,配置和账号会保留。 ``` 卸载: ```bash -curl -fsSL https://raw.githubusercontent.com/kazeyukiro/3m-ui/main/scripts/uninstall.sh | bash +sudo 3m-ui uninstall ``` --- +## Docker 安装 + +Linux VPS 下载发布版本中的 `docker-compose.yml`,在同一目录执行: + +```bash +docker compose up -d +docker compose logs 3m-ui +``` + +镜像为 `ghcr.io/kazeyukiro/3m-ui:latest`,包含面板及固定版本的 Mihomo;`latest` 只跟随稳定版。配置、随机密钥和管理员会自动初始化,首次密码只在首次启动输出一次。默认 host 网络,新增节点后放行实际使用的 TCP/UDP 端口。另提供 `docker-compose.bridge.yml`,以及版本固定、证书配置和持久化恢复说明,见 [安装文档](docs/installation.md)。 + +--- + ## 从源码构建 ```bash @@ -177,12 +189,12 @@ JWT / 凭据密钥请使用独立随机值(≥ 32 字节),不要使用文 ### Install ```bash -curl -fsSL https://raw.githubusercontent.com/kazeyukiro/3m-ui/main/scripts/install.sh | bash +curl -fsSL https://raw.githubusercontent.com/kazeyukiro/3m-ui/main/scripts/install.sh | sudo sh ``` Update after install: `sudo 3m-ui update` (optional tag: `sudo 3m-ui update v1.0.0`). -Default panel: `http://SERVER_IP:8080/` — user `admin` / password `admin` (**change on first login**). +Default panel: `http://SERVER_IP:8080/` — user `admin` with a randomly generated initial password, printed once (**change on first login**). Native bundles and Docker images include the pinned Mihomo core. See [installation, upgrade and recovery](docs/installation.md). ### Build diff --git a/backend/.gitignore b/backend/.gitignore index b48150a..e460743 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,6 +1,6 @@ # Binaries -server -server.exe +/server +/server.exe # SQLite database and dynamic config data data/ @@ -10,3 +10,6 @@ backend/data/ # Logs *.log backend_run.log + +# Generated embedded frontend (the tracked placeholder remains in Git) +/cmd/server/web/dist/ diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 0465877..1eb298b 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -8,6 +8,7 @@ import ( "github.com/kazeyukiro/3m-ui/backend/internal/app" "github.com/kazeyukiro/3m-ui/backend/internal/auth" + "github.com/kazeyukiro/3m-ui/backend/internal/bootstrap" "github.com/kazeyukiro/3m-ui/backend/internal/config" "github.com/kazeyukiro/3m-ui/backend/internal/database" ) @@ -32,8 +33,8 @@ func main() { log.Fatalf("set config path: %v", err) } i++ - case "reset-admin": - cmd = "reset-admin" + case "reset-admin", "init", "healthcheck", "storage-paths": + cmd = args[i] default: if cmd == "" && !hasPrefixDash(args[i]) { cmd = args[i] @@ -42,6 +43,33 @@ func main() { } } } + if configPath == "" { + configPath = config.ConfigPath() + } + if cmd == "init" { + initialized, err := bootstrap.Initialize(configPath) + if err != nil { + log.Fatal(err) + } + if sqlDB, err := initialized.DB.DB(); err == nil { + defer sqlDB.Close() + } + initialized.PrintCredentials(os.Stdout) + fmt.Printf("Configuration: %s\nPanel port: %d\n", configPath, initialized.Config.Server.Port) + return + } + if cmd == "healthcheck" { + if err := runHealthcheck(configPath); err != nil { + log.Fatal(err) + } + return + } + if cmd == "storage-paths" { + if err := printStoragePaths(configPath, os.Stdout); err != nil { + log.Fatal(err) + } + return + } if cmd == "reset-admin" { if err := runResetAdmin(configPath); err != nil { @@ -78,11 +106,17 @@ func runResetAdmin(configPath string) error { if err != nil { return fmt.Errorf("open database: %w", err) } - // Keep initial default password semantics: admin / admin - if err := auth.ResetAdminPassword(db, "admin"); err != nil { + if sqlDB, err := db.DB(); err == nil { + defer sqlDB.Close() + } + password, err := auth.GeneratePassword() + if err != nil { return err } - fmt.Println("Administrator password reset to: admin") + if err := auth.ResetAdminPassword(db, password); err != nil { + return err + } + fmt.Printf("Administrator password reset to: %s\n", password) fmt.Println("You must change the password on next login.") return nil } diff --git a/backend/cmd/server/maintenance.go b/backend/cmd/server/maintenance.go new file mode 100644 index 0000000..a480bfc --- /dev/null +++ b/backend/cmd/server/maintenance.go @@ -0,0 +1,171 @@ +package main + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "io" + "io/fs" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/kazeyukiro/3m-ui/backend/internal/acme" + "github.com/kazeyukiro/3m-ui/backend/internal/certstore" + "github.com/kazeyukiro/3m-ui/backend/internal/config" + "github.com/kazeyukiro/3m-ui/backend/internal/database" +) + +func readSSLSettings(cfg *config.Config) (acme.Settings, error) { + db, err := database.OpenReadOnly(cfg.Database.Path) + if err != nil { + return acme.Settings{}, err + } + sqlDB, err := db.DB() + if err != nil { + return acme.Settings{}, err + } + defer sqlDB.Close() + return acme.LoadSettings(db) +} + +func probeAddress(listen string, defaultPort int) string { + host, port, err := net.SplitHostPort(listen) + if err != nil { + host, port = listen, strconv.Itoa(defaultPort) + } + if host == "" || host == "0.0.0.0" { + host = "127.0.0.1" + } else if host == "::" { + host = "::1" + } + return net.JoinHostPort(host, port) +} + +func runHealthcheck(path string) error { + cfg, err := config.LoadConfig(path) + if err != nil { + return err + } + ssl, err := readSSLSettings(cfg) + if err != nil { + return fmt.Errorf("read panel settings: %w", err) + } + addresses := []string{"http://" + probeAddress(cfg.Server.Listen, cfg.Server.Port)} + if ssl.Enabled { + addresses = append([]string{"https://" + probeAddress(ssl.ListenTLS, cfg.Server.Port)}, addresses...) + } + client := &http.Client{ + Timeout: 2 * time.Second, + // This local readiness probe validates the application's response, not + // public certificate trust. ACME certificates usually name a domain, + // while the connection goes directly to the configured local listener. + Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true, ServerName: ssl.Domain}}, // #nosec G402 + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, + } + defer client.CloseIdleConnections() + for _, address := range addresses { + response, err := client.Get(address + "/api/v1/health") + if err != nil { + continue + } + var body struct { + Status string `json:"status"` + } + err = json.NewDecoder(io.LimitReader(response.Body, 4096)).Decode(&body) + response.Body.Close() + if response.StatusCode == http.StatusOK && err == nil && body.Status == "ok" { + return nil + } + } + return fmt.Errorf("panel health endpoint did not respond successfully") +} + +// printStoragePaths reports paths only, without reading out any credentials. +// Maintenance scripts use the candidate binary before changing the install, +// so even an older installation can be inventoried without a DB migration. +func printStoragePaths(path string, out io.Writer) error { + cfg, err := config.LoadConfig(path) + if err != nil { + return err + } + ssl, err := readSSLSettings(cfg) + if err != nil { + return err + } + paths := []string{path, cfg.Database.Path, cfg.Database.Path + "-wal", cfg.Database.Path + "-shm", certstore.Dir()} + if cfg.Mihomo.Config != "" { + paths = append(paths, cfg.Mihomo.Config, filepath.Dir(cfg.Mihomo.Config)) + } + paths = append(paths, ssl.CacheDir, ssl.CertFile, ssl.KeyFile) + return printResolvedStoragePaths(paths, out) +} + +func printResolvedStoragePaths(paths []string, out io.Writer) error { + seen := make(map[string]bool) + for index := 0; index < len(paths); index++ { + item := paths[index] + if item == "" { + continue + } + abs, err := filepath.Abs(item) + if err != nil { + return err + } + // Include the target of e.g. Let's Encrypt live certificate symlinks. + resolved, err := filepath.EvalSymlinks(abs) + if err != nil && !os.IsNotExist(err) { + return err + } + for _, name := range []string{abs, resolved} { + if name == "" || seen[name] { + continue + } + if strings.ContainsAny(name, "\r\n") { + return fmt.Errorf("storage path contains a newline") + } + seen[name] = true + if _, err := fmt.Fprintln(out, name); err != nil { + return err + } + } + // tar preserves symlinks, so inventory links below certificate/core + // directories as well as the configured paths themselves. Do not follow + // nested directory links: their contents cannot be assumed to be part of + // the snapshot, even when the directory target is under a managed root. + if resolved == "" { + continue + } + info, err := os.Stat(resolved) + if err != nil { + return err + } + if !info.IsDir() { + continue + } + if err := filepath.WalkDir(resolved, func(name string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.Type()&os.ModeSymlink == 0 { + return nil + } + target, err := os.Stat(name) + if err != nil { + return fmt.Errorf("inspect storage symlink %s: %w", name, err) + } + if target.IsDir() { + return fmt.Errorf("automatic snapshots do not support nested directory symlinks: %s", name) + } + paths = append(paths, name) + return nil + }); err != nil { + return err + } + } + return nil +} diff --git a/backend/cmd/server/maintenance_test.go b/backend/cmd/server/maintenance_test.go new file mode 100644 index 0000000..63a83da --- /dev/null +++ b/backend/cmd/server/maintenance_test.go @@ -0,0 +1,157 @@ +package main + +import ( + "bytes" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/kazeyukiro/3m-ui/backend/internal/acme" + "github.com/kazeyukiro/3m-ui/backend/internal/bootstrap" + "github.com/kazeyukiro/3m-ui/backend/internal/config" + "github.com/kazeyukiro/3m-ui/backend/internal/database/models" +) + +func TestHealthcheckUsesActualHTTPAndTLSListeners(t *testing.T) { + for _, useTLS := range []bool{false, true} { + t.Run(strconv.FormatBool(useTLS), func(t *testing.T) { + root := t.TempDir() + t.Setenv("THREE_M_UI_DATA_DIR", filepath.Join(root, "data")) + t.Setenv("THREE_M_UI_PORT", "") + t.Setenv("PANEL_PORT", "") + path := filepath.Join(root, "config.yaml") + initialized, err := bootstrap.Initialize(path) + if err != nil { + t.Fatal(err) + } + db, _ := initialized.DB.DB() + defer db.Close() + valid := true + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/health" { + t.Error("unexpected probe path") + } + if valid { + w.Write([]byte(`{"status":"ok"}`)) + } else { + w.Write([]byte("unrelated service")) + } + }) + var server *httptest.Server + if useTLS { + server = httptest.NewTLSServer(handler) + } else { + server = httptest.NewServer(handler) + } + defer server.Close() + address := strings.TrimPrefix(strings.TrimPrefix(server.URL, "https://"), "http://") + host, portText, _ := net.SplitHostPort(address) + port, _ := strconv.Atoi(portText) + if useTLS { + s := acme.DefaultSettings() + s.Enabled, s.ListenTLS = true, address + raw, _ := json.Marshal(s) + if err := initialized.DB.Create(&models.PanelSetting{Key: "panel_ssl", Value: string(raw)}).Error; err != nil { + t.Fatal(err) + } + } else if err := config.UpdateServerFile(path, port, host, "", false); err != nil { + t.Fatal(err) + } + if err := runHealthcheck(path); err != nil { + t.Fatal(err) + } + valid = false + if err := runHealthcheck(path); err == nil { + t.Fatal("HTTP 200 without the health payload must fail") + } + }) + } +} + +func TestStoragePathsIncludesCertificateTargetsWithoutChangingConfig(t *testing.T) { + root := t.TempDir() + t.Setenv("THREE_M_UI_DATA_DIR", filepath.Join(root, "data")) + t.Setenv("3M_UI_CERT_DIR", filepath.Join(root, "listener-certs")) + path := filepath.Join(root, "config.yaml") + initialized, err := bootstrap.Initialize(path) + if err != nil { + t.Fatal(err) + } + db, _ := initialized.DB.DB() + defer db.Close() + cert := filepath.Join(root, "cert.pem") + link := filepath.Join(root, "live.pem") + if err := os.WriteFile(cert, []byte("certificate fixture"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(cert, link); err != nil { + t.Fatal(err) + } + coreConfig := filepath.Join(root, "external-core.yaml") + if err := os.WriteFile(coreConfig, []byte("rules: [MATCH,DIRECT]\n"), 0600); err != nil { + t.Fatal(err) + } + coreLink := initialized.Config.Mihomo.Config + if err := os.MkdirAll(filepath.Dir(coreLink), 0700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(coreConfig, coreLink); err != nil { + t.Fatal(err) + } + listenerKey := filepath.Join(root, "listener-certs", "nested", "1.key") + if err := os.MkdirAll(filepath.Dir(listenerKey), 0700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(cert, listenerKey); err != nil { + t.Fatal(err) + } + ssl := acme.DefaultSettings() + ssl.CertFile = link + raw, _ := json.Marshal(ssl) + if err := initialized.DB.Create(&models.PanelSetting{Key: "panel_ssl", Value: string(raw)}).Error; err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(path) + var output bytes.Buffer + if err := printStoragePaths(path, &output); err != nil { + t.Fatal(err) + } + for _, expected := range []string{path, initialized.Config.Database.Path, link, cert, coreLink, coreConfig, listenerKey} { + if !strings.Contains(output.String(), expected+"\n") { + t.Errorf("missing storage path %s", expected) + } + } + if strings.Contains(output.String(), initialized.Config.JWT.Secret) { + t.Fatal("storage inventory exposed a secret") + } + after, _ := os.ReadFile(path) + if !bytes.Equal(before, after) { + t.Fatal("read-only storage inventory modified config") + } +} + +func TestStorageInventoryRejectsNestedDirectorySymlinks(t *testing.T) { + root := t.TempDir() + certificates := filepath.Join(root, "certificates") + shared := filepath.Join(root, "shared") + for _, dir := range []string{certificates, shared} { + if err := os.Mkdir(dir, 0700); err != nil { + t.Fatal(err) + } + } + link := filepath.Join(certificates, "linked-directory") + if err := os.Symlink(shared, link); err != nil { + t.Fatal(err) + } + var output bytes.Buffer + err := printResolvedStoragePaths([]string{certificates}, &output) + if err == nil || !strings.Contains(err.Error(), "nested directory symlinks") { + t.Fatalf("expected an explicit unsupported directory symlink error, got %v", err) + } +} diff --git a/backend/cmd/server/version.go b/backend/cmd/server/version.go index 68949c2..896574f 100644 --- a/backend/cmd/server/version.go +++ b/backend/cmd/server/version.go @@ -3,7 +3,7 @@ package main import "fmt" var ( - version = "v1.0.0" + version = "dev" gitCommit = "unknown" buildTime = "unknown" ) diff --git a/backend/config/config.yaml b/backend/config/config.yaml index 71ae676..ab4f15f 100644 --- a/backend/config/config.yaml +++ b/backend/config/config.yaml @@ -1,6 +1,7 @@ # 3M-UI configuration file. # This file contains PLACEHOLDER secrets that will be REJECTED on startup. -# Use the installer (scripts/install.sh) to generate random secrets, OR +# A missing configuration is generated by the application on first startup. +# This example is for explicit custom configuration only. Run `3m-ui init`, OR # manually replace with random 32+ byte strings before first run. # Generate with: openssl rand -hex 32 @@ -21,5 +22,5 @@ security: credential_key: "CHANGE_ME_TO_RANDOM_32_PLUS_BYTES" # rejected by Validate; use install.sh or set random hex mihomo: - binary: "/usr/local/bin/mihomo" + binary: "/usr/local/lib/3m-ui/mihomo" config: "backend/data/mihomo/config.yaml" diff --git a/backend/internal/acme/manager.go b/backend/internal/acme/manager.go index f401a88..44c58b1 100644 --- a/backend/internal/acme/manager.go +++ b/backend/internal/acme/manager.go @@ -12,6 +12,7 @@ import ( "strings" "sync" + "github.com/kazeyukiro/3m-ui/backend/internal/config" "github.com/kazeyukiro/3m-ui/backend/internal/database/models" "golang.org/x/crypto/acme/autocert" "gorm.io/gorm" @@ -32,8 +33,12 @@ type Settings struct { } func DefaultSettings() Settings { + cacheDir := "/var/lib/3m-ui/acme" + if cfg := config.GlobalConfig; cfg != nil && cfg.Database.Path != "" { + cacheDir = filepath.Join(filepath.Dir(cfg.Database.Path), "acme") + } return Settings{ - CacheDir: "/var/lib/3m-ui/acme", + CacheDir: cacheDir, ListenHTTP: ":80", ListenTLS: ":443", } diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 4f6ffbf..11df1e9 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -13,10 +13,9 @@ import ( "github.com/gin-gonic/gin" "github.com/kazeyukiro/3m-ui/backend/internal/acme" - "github.com/kazeyukiro/3m-ui/backend/internal/auth" + "github.com/kazeyukiro/3m-ui/backend/internal/bootstrap" "github.com/kazeyukiro/3m-ui/backend/internal/certstore" "github.com/kazeyukiro/3m-ui/backend/internal/config" - "github.com/kazeyukiro/3m-ui/backend/internal/database" "github.com/kazeyukiro/3m-ui/backend/internal/database/models" "github.com/kazeyukiro/3m-ui/backend/internal/mihomo" dbconfig "github.com/kazeyukiro/3m-ui/backend/internal/mihomo/config" @@ -27,19 +26,12 @@ import ( // Run boots the application and serves the embedded frontend. func Run(frontendFS fs.FS) error { configPath := defaultConfigPath() - cfg, err := config.LoadConfig(configPath) + initialized, err := bootstrap.Initialize(configPath) if err != nil { - return fmt.Errorf("load config: %w", err) - } - db, err := database.InitDB(cfg.Database.Path) - if err != nil { - return fmt.Errorf("initialize database: %w", err) - } - if created, username, _, err := auth.EnsureAdmin(db, cfg.Database.Path); err != nil { - return fmt.Errorf("initialize administrator: %w", err) - } else if created { - log.Printf("initial administrator created: username=%s", username) + return err } + cfg, db := initialized.Config, initialized.DB + initialized.PrintCredentials(os.Stdout) security.InitCredentialKey(cfg.Security.CredentialKey) container := NewContainer(db, cfg) @@ -192,15 +184,7 @@ func serveWithSSL(handler http.Handler, s acme.Settings, fallbackPort int) error } func defaultConfigPath() string { - if value := os.Getenv("THREE_M_UI_CONFIG"); value != "" { - return value - } - for _, candidate := range []string{"/etc/3m-ui/config.yaml", "config/config.yaml", "backend/config/config.yaml"} { - if _, err := os.Stat(candidate); err == nil { - return candidate - } - } - return "config/config.yaml" + return config.ConfigPath() } func mountFrontend(r *gin.Engine, frontendFS fs.FS) { diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go index 4daa540..f91c872 100644 --- a/backend/internal/auth/service.go +++ b/backend/internal/auth/service.go @@ -1,10 +1,10 @@ package auth import ( + "crypto/rand" "encoding/base64" "errors" "fmt" - "log" "os" "strings" "time" @@ -54,7 +54,8 @@ func Login(db *gorm.DB, jwtSecret string, input LoginInput) (*LoginResult, error // EnsureAdmin creates the first administrator only when the database has no // administrator. An explicit THREE_M_UI_ADMIN_PASSWORD is preferred. -// When THREE_M_UI_ADMIN_PASSWORD is unset, the initial password is "admin". +// When THREE_M_UI_ADMIN_PASSWORD is unset, a random password is returned once +// to the caller. Only its bcrypt hash is stored in the database. // RequireAuth forces a password change on first login (MustChangePassword=true) // before any other API can be used. Change it immediately on first login. func EnsureAdmin(db *gorm.DB, dbPath string) (created bool, username, password string, err error) { @@ -72,7 +73,13 @@ func EnsureAdmin(db *gorm.DB, dbPath string) (created bool, username, password s } password = os.Getenv("THREE_M_UI_ADMIN_PASSWORD") if password == "" { - password = "admin" + password, err = GeneratePassword() + if err != nil { + return false, "", "", err + } + } + if len(password) < 8 || len(password) > 72 { + return false, "", "", fmt.Errorf("initial administrator password must be between 8 and 72 bytes") } hash, err := HashPassword(password) @@ -83,24 +90,31 @@ func EnsureAdmin(db *gorm.DB, dbPath string) (created bool, username, password s return false, "", "", fmt.Errorf("create initial admin: %w", err) } - log.Printf("[SECURITY] Initial administrator created: %s. Password is 'admin' (or THREE_M_UI_ADMIN_PASSWORD). Change it immediately on first login.", username) - return true, username, password, nil } +// GeneratePassword creates a cryptographically random initial or reset password. +func GeneratePassword() (string, error) { + var raw [24]byte + if _, err := rand.Read(raw[:]); err != nil { + return "", fmt.Errorf("generate administrator password: %w", err) + } + return base64.RawURLEncoding.EncodeToString(raw[:]), nil +} + func EncodePassword(password string) string { return base64.RawURLEncoding.EncodeToString([]byte(password)) } // ResetAdminPassword sets the first administrator password to the given plaintext -// (default "admin") and forces password change on next login. Does not create +// and forces password change on next login. Does not create // an admin if none exists. func ResetAdminPassword(db *gorm.DB, plaintext string) error { if db == nil { return fmt.Errorf("database is nil") } - if strings.TrimSpace(plaintext) == "" { - plaintext = "admin" + if len(plaintext) < 8 || len(plaintext) > 72 { + return fmt.Errorf("administrator password must be between 8 and 72 bytes") } var u models.User if err := db.Where("role = ?", "admin").Order("id asc").First(&u).Error; err != nil { diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go new file mode 100644 index 0000000..c5616fe --- /dev/null +++ b/backend/internal/bootstrap/bootstrap.go @@ -0,0 +1,51 @@ +// Package bootstrap owns the shared first-run initialization for native and +// container installations. It never replaces an existing administrator. +package bootstrap + +import ( + "fmt" + "io" + + "github.com/kazeyukiro/3m-ui/backend/internal/auth" + "github.com/kazeyukiro/3m-ui/backend/internal/config" + "github.com/kazeyukiro/3m-ui/backend/internal/database" + "gorm.io/gorm" +) + +type Result struct { + Config *config.Config + DB *gorm.DB + Created bool + Username string + Password string +} + +func Initialize(path string) (*Result, error) { + if err := config.EnsureConfig(path); err != nil { + return nil, fmt.Errorf("initialize configuration: %w", err) + } + cfg, err := config.LoadConfig(path) + if err != nil { + return nil, fmt.Errorf("load configuration: %w", err) + } + db, err := database.InitDB(cfg.Database.Path) + if err != nil { + return nil, fmt.Errorf("initialize database: %w", err) + } + created, username, password, err := auth.EnsureAdmin(db, cfg.Database.Path) + if err != nil { + if sqlDB, closeErr := db.DB(); closeErr == nil { + sqlDB.Close() + } + return nil, fmt.Errorf("initialize administrator: %w", err) + } + return &Result{Config: cfg, DB: db, Created: created, Username: username, Password: password}, nil +} + +// PrintCredentials is deliberately limited to creation. Re-running init or +// restarting a container cannot reveal or reset an existing user's password. +func (r *Result) PrintCredentials(w io.Writer) { + if r.Created { + fmt.Fprintf(w, "Initial administrator: %s\nInitial password: %s\nChange this password on first login. It will not be displayed again.\n", r.Username, r.Password) + } +} diff --git a/backend/internal/bootstrap/bootstrap_test.go b/backend/internal/bootstrap/bootstrap_test.go new file mode 100644 index 0000000..9795cf0 --- /dev/null +++ b/backend/internal/bootstrap/bootstrap_test.go @@ -0,0 +1,126 @@ +package bootstrap + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/kazeyukiro/3m-ui/backend/internal/auth" + "github.com/kazeyukiro/3m-ui/backend/internal/database/models" +) + +func TestInitializePreservesCredentialsAndConfiguration(t *testing.T) { + root := t.TempDir() + t.Setenv("THREE_M_UI_DATA_DIR", filepath.Join(root, "data")) + t.Setenv("THREE_M_UI_ADMIN_PASSWORD", "") + t.Setenv("THREE_M_UI_ADMIN_USERNAME", "") + t.Setenv("THREE_M_UI_PORT", "8099") + path := filepath.Join(root, "config", "config.yaml") + first, err := Initialize(path) + if err != nil { + t.Fatal(err) + } + firstDB, _ := first.DB.DB() + defer firstDB.Close() + if !first.Created || first.Username != "admin" || len(first.Password) < 24 || first.Password == "admin" { + t.Fatal("expected a newly generated administrator") + } + if first.Config.Server.Port != 8099 || first.Config.JWT.Secret == first.Config.Security.CredentialKey { + t.Fatal("configuration defaults or independent secrets are incorrect") + } + var user models.User + if err := first.DB.First(&user).Error; err != nil { + t.Fatal(err) + } + if !user.MustChangePassword || !auth.CheckPasswordHash(first.Password, user.PasswordHash) { + t.Fatal("administrator must use a password hash and require a password change") + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(raw, []byte(first.Password)) { + t.Fatal("password must not be written to config") + } + info, _ := os.Stat(path) + if info.Mode().Perm() != 0600 { + t.Fatalf("config mode = %o", info.Mode().Perm()) + } + var printed bytes.Buffer + first.PrintCredentials(&printed) + if !bytes.Contains(printed.Bytes(), []byte(first.Password)) { + t.Fatal("initial credentials must be available once") + } + firstDB.Close() + t.Setenv("THREE_M_UI_ADMIN_PASSWORD", "replacement-must-not-apply") + second, err := Initialize(path) + if err != nil { + t.Fatal(err) + } + secondDB, _ := second.DB.DB() + defer secondDB.Close() + if second.Created || second.Password != "" { + t.Fatal("restart recreated administrator") + } + printed.Reset() + second.PrintCredentials(&printed) + if printed.Len() != 0 { + t.Fatal("restart displayed existing credentials") + } + after, _ := os.ReadFile(path) + if !bytes.Equal(raw, after) { + t.Fatal("restart changed persisted configuration") + } + var persisted models.User + second.DB.First(&persisted) + if persisted.PasswordHash != user.PasswordHash { + t.Fatal("restart changed password hash") + } +} + +func TestInitializeRejectsBadConfigWithoutReplacingIt(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + bad := []byte("server: [broken\n") + if err := os.WriteFile(path, bad, 0600); err != nil { + t.Fatal(err) + } + if _, err := Initialize(path); err == nil { + t.Fatal("malformed existing config must fail") + } + after, _ := os.ReadFile(path) + if !bytes.Equal(bad, after) { + t.Fatal("invalid config was overwritten") + } +} + +func TestExplicitPasswordAndReset(t *testing.T) { + root := t.TempDir() + t.Setenv("THREE_M_UI_DATA_DIR", filepath.Join(root, "data")) + t.Setenv("THREE_M_UI_ADMIN_USERNAME", "operator") + t.Setenv("THREE_M_UI_ADMIN_PASSWORD", "explicit-bootstrap-password") + initialized, err := Initialize(filepath.Join(root, "config.yaml")) + if err != nil { + t.Fatal(err) + } + sqlDB, _ := initialized.DB.DB() + defer sqlDB.Close() + if initialized.Username != "operator" || initialized.Password != "explicit-bootstrap-password" { + t.Fatal("explicit bootstrap credentials ignored") + } + password, err := auth.GeneratePassword() + if err != nil { + t.Fatal(err) + } + if err := auth.ResetAdminPassword(initialized.DB, password); err != nil { + t.Fatal(err) + } + var user models.User + initialized.DB.First(&user) + if !auth.CheckPasswordHash(password, user.PasswordHash) || user.SessionVersion != 2 || !user.MustChangePassword { + t.Fatal("reset must invalidate sessions and require password change") + } + if err := auth.ResetAdminPassword(initialized.DB, ""); err == nil { + t.Fatal("empty reset must not restore admin/admin") + } +} diff --git a/backend/internal/certstore/store.go b/backend/internal/certstore/store.go index 1f4122d..fe86668 100644 --- a/backend/internal/certstore/store.go +++ b/backend/internal/certstore/store.go @@ -6,6 +6,8 @@ import ( "strconv" "strings" "sync" + + "github.com/kazeyukiro/3m-ui/backend/internal/config" ) // Dir returns the directory for durable per-listener TLS material. @@ -19,6 +21,9 @@ func Dir() string { if d := strings.TrimSpace(os.Getenv("3M_UI_DATA")); d != "" { return filepath.Join(d, "listener-certs") } + if cfg := config.GlobalConfig; cfg != nil && cfg.Database.Path != "" { + return filepath.Join(filepath.Dir(cfg.Database.Path), "listener-certs") + } return "/var/lib/3m-ui/listener-certs" } diff --git a/backend/internal/config/bootstrap.go b/backend/internal/config/bootstrap.go new file mode 100644 index 0000000..3180484 --- /dev/null +++ b/backend/internal/config/bootstrap.go @@ -0,0 +1,95 @@ +package config + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +const DefaultConfigPath = "/etc/3m-ui/config.yaml" + +// EnsureConfig initializes a missing configuration without replacing existing +// settings or encryption keys. Both the installer and container use this path. +func EnsureConfig(path string) error { + if _, err := os.Stat(path); err == nil { + return nil + } else if !os.IsNotExist(err) { + return err + } + dataDir := strings.TrimSpace(os.Getenv("THREE_M_UI_DATA_DIR")) + if dataDir == "" { + dataDir = "/var/lib/3m-ui" + } + binary := strings.TrimSpace(os.Getenv("THREE_M_UI_MIHOMO_BINARY")) + if binary == "" { + binary = "/usr/local/lib/3m-ui/mihomo" + } + secret, err := randomSecret() + if err != nil { + return err + } + credentialKey, err := randomSecret() + if err != nil { + return err + } + cfg := Config{ + Server: ServerConfig{Port: 8080, Mode: "release"}, + Database: DatabaseConfig{Path: filepath.Join(dataDir, "3m-ui.db")}, + JWT: JWTConfig{Secret: secret}, + Security: SecurityConfig{CredentialKey: credentialKey}, + Mihomo: MihomoConfig{Binary: binary, Config: filepath.Join(dataDir, "mihomo", "config.yaml")}, + } + ApplyEnvOverrides(&cfg) + if err := Validate(&cfg); err != nil { + return err + } + var raw bytes.Buffer + encoder := yaml.NewEncoder(&raw) + encoder.SetIndent(2) + if err := encoder.Encode(&cfg); err != nil { + return err + } + if err := encoder.Close(); err != nil { + return err + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0700); err != nil { + return fmt.Errorf("create configuration directory: %w", err) + } + // A same-directory temporary file and exclusive link avoid publishing partial + // YAML or replacing a configuration created by another initial startup. + tmp, err := os.CreateTemp(dir, ".config-*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if _, err := tmp.Write(raw.Bytes()); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Link(tmp.Name(), path); err != nil && !os.IsExist(err) { + return fmt.Errorf("create configuration: %w", err) + } + return nil +} + +func randomSecret() (string, error) { + var raw [32]byte + if _, err := rand.Read(raw[:]); err != nil { + return "", fmt.Errorf("generate configuration secret: %w", err) + } + return hex.EncodeToString(raw[:]), nil +} diff --git a/backend/internal/config/bootstrap_test.go b/backend/internal/config/bootstrap_test.go new file mode 100644 index 0000000..fa9db75 --- /dev/null +++ b/backend/internal/config/bootstrap_test.go @@ -0,0 +1,39 @@ +package config + +import ( + "os" + "path/filepath" + "sync" + "testing" +) + +func TestConcurrentEnsureConfigCreatesOneValidFile(t *testing.T) { + root := t.TempDir() + t.Setenv("THREE_M_UI_DATA_DIR", root) + path := filepath.Join(root, "config.yaml") + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if err := EnsureConfig(path); err != nil { + t.Error(err) + } + }() + } + wg.Wait() + cfg, err := LoadConfig(path) + if err != nil { + t.Fatal(err) + } + if len(cfg.JWT.Secret) != 64 || len(cfg.Security.CredentialKey) != 64 { + t.Fatal("invalid secret lengths") + } + files, _ := filepath.Glob(filepath.Join(root, ".config-*")) + if len(files) != 0 { + t.Fatal("initialization left temporary secrets") + } + if _, err := os.Stat(path); err != nil { + t.Fatal(err) + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 37bff6c..9d9f52c 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "net" "os" "strconv" "strings" @@ -130,7 +131,7 @@ func ConfigPath() string { return candidate } } - return "config/config.yaml" + return DefaultConfigPath } // UpdateServerFile writes server.port / listen / public_url into the YAML config @@ -192,6 +193,14 @@ func Validate(cfg *Config) error { if cfg.Server.Port < 1 || cfg.Server.Port > 65535 { return fmt.Errorf("server.port must be between 1 and 65535") } + if address := strings.TrimSpace(os.Getenv("THREE_M_UI_MIHOMO_CONTROLLER")); address != "" { + host, portText, err := net.SplitHostPort(address) + port, portErr := strconv.Atoi(portText) + ip := net.ParseIP(host) + if err != nil || portErr != nil || port < 1 || port > 65535 || ip == nil || !ip.IsLoopback() { + return fmt.Errorf("THREE_M_UI_MIHOMO_CONTROLLER must be a loopback IP and port") + } + } if strings.TrimSpace(cfg.Database.Path) == "" { return fmt.Errorf("database.path is required") } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index fabec01..2500266 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -52,3 +52,23 @@ func TestValidateAcceptsSecureConfig(t *testing.T) { t.Fatalf("expected secure config to pass validation: %v", err) } } + +func TestControllerOverrideRemainsLocal(t *testing.T) { + cfg := &Config{ + Server: ServerConfig{Port: 8080}, Database: DatabaseConfig{Path: "test.db"}, + JWT: JWTConfig{Secret: "a-unique-jwt-secret-that-is-long-enough"}, + Security: SecurityConfig{CredentialKey: "a-unique-credential-key-that-is-long-enough"}, + } + for _, address := range []string{"0.0.0.0:9090", "198.51.100.1:9090", "127.0.0.1:0", "127.0.0.1:65536", "localhost:9090"} { + t.Setenv("THREE_M_UI_MIHOMO_CONTROLLER", address) + if err := Validate(cfg); err == nil { + t.Errorf("accepted invalid controller %s", address) + } + } + for _, address := range []string{"127.0.0.1:9099", "[::1]:9099"} { + t.Setenv("THREE_M_UI_MIHOMO_CONTROLLER", address) + if err := Validate(cfg); err != nil { + t.Errorf("rejected loopback controller: %v", err) + } + } +} diff --git a/backend/internal/database/readonly.go b/backend/internal/database/readonly.go new file mode 100644 index 0000000..90193af --- /dev/null +++ b/backend/internal/database/readonly.go @@ -0,0 +1,23 @@ +package database + +import ( + "net/url" + "path/filepath" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// OpenReadOnly is for local maintenance commands. It must never create a +// database, migrate its schema, or modify the running application's settings. +func OpenReadOnly(path string) (*gorm.DB, error) { + abs, err := filepath.Abs(path) + if err != nil { + return nil, err + } + uri := url.URL{Scheme: "file", Path: abs, RawQuery: "mode=ro"} + return gorm.Open(sqlite.New(sqlite.Config{DriverName: sqliteDriverName, DSN: uri.String()}), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) +} diff --git a/backend/internal/mihomo/config/template.go b/backend/internal/mihomo/config/template.go index 852a68f..6d5b8f2 100644 --- a/backend/internal/mihomo/config/template.go +++ b/backend/internal/mihomo/config/template.go @@ -3,6 +3,8 @@ package config import ( "crypto/rand" "encoding/hex" + "os" + "strings" "sync" ) @@ -38,12 +40,16 @@ func ControllerSecret() string { // GetDefaultTemplate returns a deliberately minimal, localhost-safe base // configuration. Listener definitions are appended from the database. func GetDefaultTemplate() *MihomoConfig { + controller := strings.TrimSpace(os.Getenv("THREE_M_UI_MIHOMO_CONTROLLER")) + if controller == "" { + controller = "127.0.0.1:9090" + } return &MihomoConfig{ Mode: "rule", LogLevel: "info", AllowLan: false, IPv6: false, - ExternalController: "127.0.0.1:9090", + ExternalController: controller, // The controller is bound to loopback, but a process-scoped random // secret is still applied so any co-located unprivileged process // cannot drive Mihomo's REST API without first reading the secret @@ -55,7 +61,8 @@ func GetDefaultTemplate() *MihomoConfig { Proxies: []map[string]interface{}{}, ProxyGroups: []map[string]interface{}{}, Rules: []string{ - "GEOIP,CN,DIRECT", + // All traffic defaults to DIRECT. A redundant GEOIP rule would + // require a network download before a clean installation can start. "MATCH,DIRECT", }, } diff --git a/backend/internal/mihomo/config/template_install_test.go b/backend/internal/mihomo/config/template_install_test.go new file mode 100644 index 0000000..08c36a2 --- /dev/null +++ b/backend/internal/mihomo/config/template_install_test.go @@ -0,0 +1,16 @@ +package config + +import "testing" + +func TestFreshInstallDoesNotRequireGeoDownloads(t *testing.T) { + t.Setenv("THREE_M_UI_MIHOMO_CONTROLLER", "127.0.0.1:19090") + base := GetDefaultTemplate() + if base.ExternalController != "127.0.0.1:19090" { + t.Fatal("controller override not applied") + } + for _, rules := range [][]string{base.Rules, DefaultVisualConfig().Rules} { + if len(rules) != 1 || rules[0] != "MATCH,DIRECT" { + t.Fatal("fresh direct routing should not require external geodata") + } + } +} diff --git a/backend/internal/mihomo/config/visual.go b/backend/internal/mihomo/config/visual.go index 865e7de..8e7024c 100644 --- a/backend/internal/mihomo/config/visual.go +++ b/backend/internal/mihomo/config/visual.go @@ -95,7 +95,7 @@ func DefaultVisualConfig() VisualConfig { Nameserver: []string{"119.29.29.29", "223.5.5.5"}, }, Proxies: []ProxyEntry{}, Groups: []GroupEntry{}, - Rules: []string{"GEOIP,CN,DIRECT", "MATCH,DIRECT"}, + Rules: []string{"MATCH,DIRECT"}, } } diff --git a/backend/internal/mihomo/process.go b/backend/internal/mihomo/process.go index 0f9580d..1efd108 100644 --- a/backend/internal/mihomo/process.go +++ b/backend/internal/mihomo/process.go @@ -70,6 +70,7 @@ func (pm *ProcessManager) SetCrashHandler(fn func(exitErr error)) { // production. It prevents an attacker from tricking 3m-ui into executing an // arbitrary file by manipulating the configured binary path. var productionAllowedBinaryPrefixes = []string{ + "/usr/local/lib/3m-ui/", "/usr/local/bin/", "/usr/bin/", "/opt/", diff --git a/distribution/MIHOMO_LICENSE b/distribution/MIHOMO_LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/distribution/MIHOMO_LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + 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. + + + Copyright (C) + + 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. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +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: + + Copyright (C) + 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. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/distribution/mihomo.env b/distribution/mihomo.env new file mode 100644 index 0000000..792032a --- /dev/null +++ b/distribution/mihomo.env @@ -0,0 +1,8 @@ +# Reviewed release pair for the public amd64/arm64 bundles and Docker images. +# SHA-256 covers compressed upstream assets. Compared with GitHub asset digests +# and independently downloaded on 2026-09-08. +MIHOMO_VERSION=v1.19.30 +MIHOMO_AMD64_ASSET=mihomo-linux-amd64-v1-v1.19.30.gz +MIHOMO_AMD64_SHA256=cbe553d0319a414bd3a372c5976a252155b2c4882b66bce88a4d6bba9571a553 +MIHOMO_ARM64_ASSET=mihomo-linux-arm64-v1.19.30.gz +MIHOMO_ARM64_SHA256=58896873736d28628f66de3677c8654fa0f180662523148e136cff4f6e890069 diff --git a/docker-compose.bridge.yml b/docker-compose.bridge.yml new file mode 100644 index 0000000..834d4a0 --- /dev/null +++ b/docker-compose.bridge.yml @@ -0,0 +1,29 @@ +# Standalone alternative: docker compose -f docker-compose.bridge.yml up -d +# The panel is reachable locally at http://127.0.0.1:8080. Use a TLS reverse proxy +# for remote access, or change the panel mapping to your intended host address. +# Add a TCP and UDP mapping for every listener (or a planned port range). +services: + 3m-ui: + image: ${THREE_M_UI_IMAGE:-ghcr.io/kazeyukiro/3m-ui:latest} + init: true + restart: unless-stopped + ports: + - "127.0.0.1:8080:8080" + # - "10000-10100:10000-10100/tcp" + # - "10000-10100:10000-10100/udp" + environment: + THREE_M_UI_CONFIG: /etc/3m-ui/config.yaml + TZ: ${TZ:-UTC} + volumes: + - 3m-ui-config:/etc/3m-ui + - 3m-ui-data:/var/lib/3m-ui + - 3m-ui-logs:/var/log/3m-ui + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + +volumes: + 3m-ui-config: + 3m-ui-data: + 3m-ui-logs: diff --git a/docker-compose.yml b/docker-compose.yml index eab6dac..33acf76 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,42 +1,25 @@ -# 3m-ui — Mihomo management panel (Ant Design frontend) -# Usage: -# docker compose up -d -# Panel: http://localhost:8080/ (bound to 127.0.0.1 by default) -# -# Security notes (M-3 / M-9): -# - The panel is bound to 127.0.0.1 only. Place a reverse proxy (nginx/Caddy/ -# Traefik) in front for TLS termination and access control. To expose the -# panel directly (NOT recommended), replace the port mapping with "8080:8080". -# - Default administrator is admin/admin (forced password change on first -# login). For unattended installs, set THREE_M_UI_ADMIN_PASSWORD below. -# - Named volumes inherit UID 10001 (the non-root 3m-ui user) from the image on -# first creation. If you bind-mount host directories instead, chown them to -# 10001:10001 on the host. - +# Recommended for Linux VPS: newly-created listeners are available on the host +# without changing port mappings. Allow their TCP/UDP ports in your firewall. +# Run: docker compose up -d && docker compose logs 3m-ui +# The initial random administrator password appears once in the first-start log. +# Pin a version with THREE_M_UI_IMAGE=ghcr.io//3m-ui:v1.2.3. services: 3m-ui: - build: . - image: 3m-ui:local - container_name: 3m-ui + image: ${THREE_M_UI_IMAGE:-ghcr.io/kazeyukiro/3m-ui:latest} + init: true restart: unless-stopped - ports: - # M-3: bind loopback only by default. Use a reverse proxy for public access. - - "127.0.0.1:8080:8080" + network_mode: host environment: THREE_M_UI_CONFIG: /etc/3m-ui/config.yaml - TZ: Asia/Shanghai - # M-9: default admin is admin/admin (forced change on first login). - # Uncomment and set to override the initial password for unattended installs: - # THREE_M_UI_ADMIN_PASSWORD: change-me-before-deploy + TZ: ${TZ:-UTC} volumes: - 3m-ui-config:/etc/3m-ui - 3m-ui-data:/var/lib/3m-ui - 3m-ui-logs:/var/log/3m-ui - # Optional: share host Mihomo binary + config - # - /usr/local/bin/mihomo:/usr/local/bin/mihomo:ro - # - /etc/mihomo:/etc/mihomo - # First run: create a minimal config if missing (via entrypoint override or mount). - # Generate secrets yourself — do not use example values in production. + cap_drop: + - ALL + security_opt: + - no-new-privileges:true volumes: 3m-ui-config: diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..ffce0e6 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,173 @@ +# 安装、升级与恢复 + +3m-ui 提供两种完整安装方式:Linux 一键安装,以及预构建 Docker 镜像。两者均包含面板和固定版本的 Mihomo,使用相同的首次初始化逻辑。默认安装、更新及 Docker `latest` 只跟随稳定版。 + +## Linux 一键安装 + +完整安装包支持 Linux amd64、arm64,要求 root、systemd 或 OpenRC、可访问 GitHub 的网络。不需要 Go、Node 或系统 libsqlite3。安装过程自动补齐下载和解压工具。 + +```bash +curl -fsSL https://raw.githubusercontent.com/kazeyukiro/3m-ui/main/scripts/install.sh | sudo sh +``` + +需要先检查脚本或指定参数时,下载到本地再执行: + +```bash +curl -fsSLo install.sh https://raw.githubusercontent.com/kazeyukiro/3m-ui/main/scripts/install.sh +sudo env PANEL_PORT=8080 sh install.sh --yes +``` + +环境变量必须传给执行脚本的 `sh`,放在管道左侧的 `curl` 前面不会传递给右侧的安装程序。 + +首次安装默认使用 `8080`。放行面板和各节点实际使用的端口后,访问 `http://SERVER_IP:8080/`。安装时会显示管理员 `admin` 及随机初始密码,首次登录必须修改;遗失密码可执行 `sudo 3m-ui reset-admin`,生成新密码并使旧登录会话失效。密码不会写入配置文件或安装结果文件。 + +配置文件已存在时会保留,包括现有账号、密码、端口和密钥。重复运行安装程序用于更新程序文件,不会重置管理员。修改已安装面板的端口使用 `sudo 3m-ui config port 9000`。 + +从旧版安装脚本迁移时,先执行上面的最新安装命令一次,以安装新的快照与恢复流程;旧版 `3m-ui update` 不具备本文描述的完整备份保证。 + +### HTTPS 与对外访问 + +首次登录改密后,在「系统设置」配置面板 HTTPS:可以申请域名证书,也可以使用已有证书。已有 Nginx / Caddy 的用户可设置面板只监听 `127.0.0.1`,由反向代理提供 HTTPS。域名不是安装前置要求;HTTP 端口本身不代表已启用 HTTPS。 + +配置证书时,将自管证书放在 `/etc/3m-ui/` 或 `/var/lib/3m-ui/`,以便与数据库、密钥一起备份。域名解析、HTTP-01 验证所需端口、防火墙与云安全组需要与所选择的访问方式匹配。 + +### 版本与来源 + +`v1.2.3` 等标签是不可变的正式版本,`v1.3.0-rc.1` 是显式选择的候选版本。下列版本号仅为格式示例,请换成 Releases 中实际存在的版本。 + +```bash +sudo sh install.sh v1.2.3 +sudo 3m-ui update v1.2.3 + +# 主动加入滚动测试通道;此选择会保留至后续更新。 +sudo env THREE_M_UI_CHANNEL=pre 3m-ui update + +# 返回稳定版更新通道。 +sudo env THREE_M_UI_CHANNEL=stable 3m-ui update +``` + +没有稳定版可用时会明确失败,不会自动改装 `pre`。默认不会自动升级;执行更新命令时才下载目标版本。 + +发布自己的 fork 时,需要显式选定发行来源,例如: + +```bash +sudo env THREE_M_UI_REPO=YOUR_OWNER/3m-ui sh install.sh +``` + +此来源会保存在安装目录,后续更新沿用它。Docker 使用相同发行仓库的镜像,例如 `ghcr.io/YOUR_OWNER/3m-ui:v1.2.3`。不能假定从 fork 下载原始脚本后,默认下载地址也会自动改变。 + +所有发行下载均校验 `SHA256SUMS`,校验失败就停止。已安装 `cosign` 的用户可设置 `THREE_M_UI_VERIFY_COSIGN=1`,额外要求工作流签名验证。 + +### 自定义内核及高级架构 + +完整安装默认使用 `/usr/local/lib/3m-ui/mihomo`,不自动复用全局 `/usr/local/bin/mihomo`。既有配置指定的内核路径会保留。 + +`--no-mihomo` 只安装 / 更新面板,适用于已有外部内核,或只提供独立面板二进制的其他架构。新装时可设置 `THREE_M_UI_MIHOMO_BINARY=/absolute/path/to/mihomo`。自定义核心需要自行验证兼容性并备份;完整发行包只承诺清单指定的配套版本。 + +### 管理与备份 + +```bash +sudo 3m-ui status +sudo 3m-ui version +sudo 3m-ui logs +sudo 3m-ui restart +sudo 3m-ui backup +sudo 3m-ui update +``` + +`backup` 会短暂停服,在 `/var/lib/3m-ui/backups/` 创建完整快照,然后恢复原先的运行状态。快照包含面板程序、配套核心、管理脚本、服务定义、配置、加密密钥、数据库及其辅助文件、Mihomo 数据和证书;历史备份不会递归收入新快照。 + +更新先完成下载和校验,再停服建立一致快照,替换程序并执行初始化与 HTTP/HTTPS 健康检查。更新失败会尝试恢复整份快照,包括迁移前的数据库和密钥。恢复失败时会输出可用快照位置。 + +```bash +# 换成实际创建的绝对路径;命令会先为当前状态再做一份备份。 +sudo 3m-ui restore /var/lib/3m-ui/backups/ACTUAL-SNAPSHOT.tar.gz +``` + +快照恢复要求原安装路径一致。恢复其他版本时使用对应快照,不能只换旧二进制并假定数据库能够降级。请将重要快照复制到其他机器,定期实际演练恢复。 + +自动快照覆盖本安装的配置与数据目录。若已配置的数据库、核心配置或证书实际位于其他共享目录,脚本会在停服前报出路径并停止,避免声称已经完成备份。先把相关存储迁入受管理目录,或自行完成相应的备份和手动更新。外部核心二进制不会被替换。 + +证书、核心和 ACME 目录内的文件符号链接也会检查实际目标;嵌套目录符号链接和失效链接不支持自动快照,需要先整理为受管理目录内的实际文件和目录。 + +```bash +sudo 3m-ui uninstall # 保留配置、数据、密钥和证书 +sudo 3m-ui uninstall --purge # 明确确认后彻底删除本安装的持久化内容 +``` + +## Docker + +使用本发行仓库 Releases 中的 `docker-compose.yml`,固定 Compose 所在目录,避免改变项目名后创建另一组空卷。 + +```bash +docker compose up -d +docker compose logs 3m-ui +``` + +镜像包含面板、配套 Mihomo,首次启动自动生成配置与密钥,并在日志中输出一次随机初始密码。只有密码哈希写入数据库;首次登录改密后可按自己的日志保留策略轮转首次启动日志。配置、数据和日志使用命名卷,卷权限由镜像准备。重启与容器重建不会再次生成账号或密钥。 + +镜像以非 root 用户运行。普通监听默认不需要 `privileged` 或 `NET_ADMIN`。低于 1024 的监听端口如受宿主机限制,可以使用高端口或为确有需要的部署添加 `NET_BIND_SERVICE`;TUN 等功能的权限需要自行配置。 + +### 网络与版本 + +默认 Compose 使用 Linux host 网络。新建监听会直接绑定宿主机端口,不需要重新创建容器,仍需处理端口冲突、防火墙和安全组。 + +需要 bridge 网络时,独立使用另一个模板: + +```bash +docker compose -f docker-compose.bridge.yml up -d +``` + +bridge 模板仅把面板映射到 `127.0.0.1:8080`;公网访问需要反向代理或调整绑定。每个节点需要对应的 TCP/UDP 映射,模板包含端口范围示例。不要将 host 和 bridge 模板叠加使用。 + +建议在 Compose 同目录的 `.env` 中固定正式版本,例如: + +```dotenv +THREE_M_UI_IMAGE=ghcr.io/kazeyukiro/3m-ui:v1.2.3 +``` + +将示例换成实际发布标签。默认 `latest` 只跟随稳定版,`pre` 只用于主动参与测试。fork 的镜像仓库与发行仓库一致。 + +### Docker 升级、备份与恢复 + +升级通过替换镜像完成,容器内不运行宿主机安装脚本。升级前先拉取新镜像,再停服备份持久化目录: + +```bash +docker compose pull +docker compose stop +mkdir -p backups +chmod 700 backups +docker compose run --rm --no-deps --user 0 --entrypoint sh \ + -v "$PWD/backups:/backup" 3m-ui -c \ + 'umask 077; tar -czf /backup/before-upgrade.tar.gz /etc/3m-ui /var/lib/3m-ui' +docker compose up -d +docker compose ps +``` + +每次使用不同备份文件名;示例中的 `before-upgrade.tar.gz` 会被覆盖。证书和核心配置应存放在这两个持久化目录内。记录备份对应的镜像标签或 digest;需要恢复时停服,将 `.env` 改回该镜像,再恢复整份快照: + +```bash +docker compose stop +docker compose run --rm --no-deps --user 0 --entrypoint sh \ + -v "$PWD/backups:/backup:ro" 3m-ui -c \ + 'tar -tzf /backup/before-upgrade.tar.gz >/dev/null && + find /etc/3m-ui /var/lib/3m-ui -mindepth 1 -maxdepth 1 -exec rm -rf {} \; && + tar -xzf /backup/before-upgrade.tar.gz -C /' +docker compose up -d +``` + +恢复前还应备份当前卷。上述恢复命令先验证备份可读取,再清空这两个受管理目录的现有内容,避免将旧的 WAL 等辅助文件混入恢复结果。`docker compose down` 保留卷,`docker compose down -v` 会删除持久化卷。 + +遗失管理员密码时: + +```bash +docker compose exec 3m-ui /usr/local/bin/3m-ui reset-admin +``` + +## 维护发行版本 + +固定 Mihomo 版本、架构资产名和 SHA256 在 `distribution/mihomo.env`。调整此文件意味着更新配套内核,必须重新执行安装与核心兼容验证。原生完整包和 Docker 构建共用这份清单。 + +正式 / 候选版本工作流按标签构建,并拒绝覆盖已发布版本。滚动 `pre` 是独立通道。镜像发布到 `ghcr.io/<当前仓库所有者>/<当前仓库名>`;仓库管理员首次发布时需要确保 package 可见性适合公众拉取。 + +发布验证应涵盖两种架构、首次初始化、真实核心运行、登录改密、创建节点、实际代理连接、更新保留数据和快照恢复。仅编译成功不能证明一台全新服务器可以安装。 diff --git a/scripts/3m-ui b/scripts/3m-ui index cf9548d..3aac4ec 100644 --- a/scripts/3m-ui +++ b/scripts/3m-ui @@ -5,13 +5,17 @@ set -eu -BASE="/usr/local/lib/3m-ui" +ROOT="${THREE_M_UI_ROOT:-}" +BASE="$ROOT/usr/local/lib/3m-ui" MANAGER="$BASE/3m-ui.sh" INSTALLER="$BASE/install.sh" UPDATER="$BASE/update.sh" UNINSTALLER="$BASE/uninstall.sh" APP_BIN="$BASE/3m-ui-bin" -CONFIG_FILE="/etc/3m-ui/config.yaml" +CONFIG_FILE="$ROOT/etc/3m-ui/config.yaml" +DATA_DIR="${THREE_M_UI_DATA_DIR:-}" +if [ -z "$DATA_DIR" ] && [ -s "$BASE/DATA_DIRECTORY" ]; then DATA_DIR="$(cat "$BASE/DATA_DIRECTORY")"; fi +DATA_DIR="${DATA_DIR:-$ROOT/var/lib/3m-ui}" run_script() { script="$1" @@ -41,8 +45,8 @@ menu() { printf '%s\n' ' 7. 查看日志' printf '%s\n' ' 8. 查看版本' printf '%s\n' ' 9. 修改面板端口' - printf '%s\n' ' 10. 显示面板访问地址' - printf '%s\n' ' 11. 重置管理员密码为 admin(需改密登录)' + printf '%s\n' ' 10. 查看面板端口与 public_url' + printf '%s\n' ' 11. 重置管理员密码(随机生成,需改密登录)' printf '%s\n' ' 12. 启用 BBR(Linux)' printf '%s\n' ' 13. 更新 Geo 规则文件' printf '%s\n' ' 14. 设置 public_url(订阅外网地址)' @@ -73,7 +77,7 @@ menu() { } init_system() { - if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then + if [ -d "$ROOT/run/systemd/system" ] && command -v systemctl >/dev/null 2>&1; then printf '%s' systemd elif command -v rc-service >/dev/null 2>&1; then printf '%s' openrc @@ -117,6 +121,9 @@ logs() { version() { if [ -x "$APP_BIN" ]; then "$APP_BIN" --version + if [ -s "$BASE/MIHOMO_VERSION" ]; then printf 'Bundled Mihomo: %s\n' "$(cat "$BASE/MIHOMO_VERSION")"; fi + if [ -s "$BASE/CHANNEL" ]; then printf 'Update channel: %s\n' "$(cat "$BASE/CHANNEL")"; fi + if [ -s "$BASE/REPOSITORY" ]; then printf 'Release source: %s\n' "$(cat "$BASE/REPOSITORY")"; fi else printf '%s\n' '3m-ui 未安装。' >&2 exit 1 @@ -135,18 +142,12 @@ show_access() { if [ -n "$pub" ] && [ "$pub" != "null" ]; then printf '%s\n' "public_url: $pub" fi - printf '%s\n' '本机地址:' - printf ' http://127.0.0.1:%s/\n' "$port" - # Best-effort public IPs - ips=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1) - if [ -z "$ips" ]; then - ips=$(hostname -I 2>/dev/null || true) + printf '%s\n' '面板启用 HTTPS 后,请使用面板中配置的域名和 HTTPS 端口。' + if (cd "$DATA_DIR" && THREE_M_UI_CONFIG="$CONFIG_FILE" "$APP_BIN" healthcheck) >/dev/null 2>&1; then + printf '%s\n' '面板健康检查通过。' fi - for ip in $ips; do - [ -n "$ip" ] && printf ' http://%s:%s/\n' "$ip" "$port" - done printf '%s\n' '管理命令: 3m-ui' - printf '%s\n' '初始管理员保持 admin / admin(首次登录须改密);可用菜单 11 重置为 admin。' + printf '%s\n' '初始密码在首次安装时随机生成;若遗失,可用 3m-ui reset-admin 生成新密码。' } reset_admin() { @@ -154,11 +155,11 @@ reset_admin() { printf '%s\n' "未找到程序: $APP_BIN" >&2 return 1 fi - printf '%s\n' '将把管理员密码重置为 admin,并要求下次登录修改密码。' + printf '%s\n' '将随机生成新的管理员密码,并要求下次登录修改密码。' printf '确认? [y/N] ' IFS= read -r ans || return 0 case "$ans" in y|Y|yes|YES) ;; *) printf '%s\n' '已取消。'; return 0 ;; esac - THREE_M_UI_CONFIG="$CONFIG_FILE" "$APP_BIN" reset-admin || return 1 + (cd "$DATA_DIR" && THREE_M_UI_CONFIG="$CONFIG_FILE" "$APP_BIN" reset-admin) || return 1 } enable_bbr() { @@ -181,7 +182,7 @@ enable_bbr() { } update_geo() { - dir="/var/lib/3m-ui/mihomo" + dir="$DATA_DIR/mihomo" mkdir -p "$dir" base="https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest" printf '%s\n' "下载 Geo 到 $dir ..." @@ -247,7 +248,7 @@ change_port() { service_action restart sleep 1 if service_action status >/dev/null 2>&1; then - printf '%s\n' "✓ 3m-ui 已重启。新地址: http://0.0.0.0:$new_port" + printf '%s\n' "✓ 3m-ui 已重启。配置端口: $new_port(独立 HTTPS 端口以面板设置为准)" else printf '%s\n' '✗ 3m-ui 启动失败,请检查日志。' >&2 fi @@ -264,6 +265,9 @@ main() { logs) logs ;; version) version ;; config) shift; run_script "$MANAGER" config "$@" ;; + backup) shift; run_script "$INSTALLER" --backup "$@" ;; + restore) shift; run_script "$INSTALLER" --restore "$@" ;; + init|reset-admin|healthcheck) (cd "$DATA_DIR" && THREE_M_UI_CONFIG="$CONFIG_FILE" THREE_M_UI_DATA_DIR="$DATA_DIR" "$APP_BIN" "$cmd") ;; update|upgrade) shift run_script "$UPDATER" "$@" @@ -288,9 +292,14 @@ main() { printf '%s\n' ' config show 查看配置' printf '%s\n' ' config port <端口> 修改端口并重启' printf '%s\n' '安装与更新:' - printf '%s\n' ' update [vX.Y.Z] [--yes] 更新到指定或最新 Release' + printf '%s\n' ' update [vX.Y.Z] [--yes] 更新到指定版本或所选通道最新版本' printf '%s\n' ' install 安装 / 修复' - printf '%s\n' ' uninstall 卸载' + printf '%s\n' ' uninstall [--purge] 卸载,默认保留配置、密钥和数据' + printf '%s\n' '备份与恢复:' + printf '%s\n' ' backup 停服创建完整快照,然后恢复运行' + printf '%s\n' ' restore /path/snapshot.tar.gz [--yes] 恢复快照' + printf '%s\n' ' reset-admin 生成并显示新的随机管理员密码' + printf '%s\n' ' healthcheck 检查实际 HTTP/HTTPS 面板健康状态' printf '%s\n' '其他:' printf '%s\n' ' menu 交互式管理菜单' printf '%s\n' ' help 显示本帮助' diff --git a/scripts/3m-ui.sh b/scripts/3m-ui.sh index 140f7ab..8def56a 100644 --- a/scripts/3m-ui.sh +++ b/scripts/3m-ui.sh @@ -6,11 +6,14 @@ set -eu APP_NAME="3m-ui" -BASE="/usr/local/lib/3m-ui" +ROOT="${THREE_M_UI_ROOT:-}" +BASE="$ROOT/usr/local/lib/3m-ui" APP_BIN="$BASE/3m-ui-bin" VERSION_FILE="$BASE/VERSION" -DATA_DIR="/var/lib/3m-ui" -CONFIG_DIR="/etc/3m-ui" +DATA_DIR="${THREE_M_UI_DATA_DIR:-}" +if [ -z "$DATA_DIR" ] && [ -s "$BASE/DATA_DIRECTORY" ]; then DATA_DIR="$(cat "$BASE/DATA_DIRECTORY")"; fi +DATA_DIR="${DATA_DIR:-$ROOT/var/lib/3m-ui}" +CONFIG_DIR="$ROOT/etc/3m-ui" CONFIG_FILE="$CONFIG_DIR/config.yaml" SERVICE="3m-ui" @@ -22,7 +25,7 @@ need_root() { } init_system() { - if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then + if command -v systemctl >/dev/null 2>&1 && [ -d "$ROOT/run/systemd/system" ]; then printf '%s' systemd elif command -v rc-service >/dev/null 2>&1; then printf '%s' openrc @@ -82,24 +85,7 @@ version() { } uninstall() { - service_action stop 2>/dev/null || true - case "$(init_system)" in - systemd) - systemctl disable "$SERVICE" 2>/dev/null || true - rm -f "/etc/systemd/system/$SERVICE.service" - systemctl daemon-reload - ;; - openrc) - rc-update del "$SERVICE" default 2>/dev/null || true - rm -f "/etc/init.d/$SERVICE" - ;; - esac - rm -f /usr/local/bin/3m-ui - rm -rf "$BASE" - say "3m-ui application and management command removed." - say "Data preserved at: $DATA_DIR" - say "Configuration preserved at: $CONFIG_DIR" - say "Mihomo was not removed." + "$BASE/uninstall.sh" "$@" } config() { @@ -134,7 +120,7 @@ config() { service_action restart sleep 1 if service_is_active; then - say "✓ 3m-ui restarted. Access: http://0.0.0.0:$new_port" + say "✓ 3m-ui restarted. Configured port: $new_port (a separate HTTPS listener follows the panel TLS settings)." else say "✗ 3m-ui failed to start. Check logs: 3m-ui logs" fi @@ -165,7 +151,11 @@ Commands: logs Show recent service logs version Show installed 3m-ui version config Show or edit config (see below) - uninstall Remove 3m-ui but preserve data/config + uninstall Remove 3m-ui but preserve data/config (use --purge to delete) + backup Stop service, snapshot data/config/binaries, resume service + restore Restore a complete snapshot: restore /path/snapshot.tar.gz + reset-admin Generate and display a new random administrator password + healthcheck Probe the actual configured HTTP/HTTPS panel help Show this help Config sub-commands: @@ -187,7 +177,10 @@ main() { logs) logs ;; version) version ;; config) shift; config "$@" ;; - uninstall) uninstall ;; + uninstall) shift; uninstall "$@" ;; + backup) shift; "$BASE/install.sh" --backup "$@" ;; + restore) shift; "$BASE/install.sh" --restore "$@" ;; + init|reset-admin|healthcheck) (cd "$DATA_DIR" && THREE_M_UI_CONFIG="$CONFIG_FILE" THREE_M_UI_DATA_DIR="$DATA_DIR" "$APP_BIN" "$cmd") ;; help|-h|--help) usage ;; '') if [ -x /usr/local/bin/3m-ui ] && [ "$(readlink -f /usr/local/bin/3m-ui 2>/dev/null || true)" = "$APP_BIN" ]; then diff --git a/scripts/build-release-bundle.sh b/scripts/build-release-bundle.sh new file mode 100755 index 0000000..bf271b5 --- /dev/null +++ b/scripts/build-release-bundle.sh @@ -0,0 +1,31 @@ +#!/bin/sh +# Package an already-built static panel with its reviewed core and lifecycle scripts. +set -eu +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) +ARCH=${1:?Usage: build-release-bundle.sh amd64|arm64 PANEL_BINARY OUTPUT_DIRECTORY} +PANEL=${2:?Panel binary is required} +DEST=${3:?Output directory is required} +: "${VERSION:?Set VERSION to the release version}" +: "${REPOSITORY:?Set REPOSITORY to the owner/repository that publishes this release}" +case "$ARCH" in amd64|arm64) ;; *) echo "Unsupported bundle architecture: $ARCH" >&2; exit 1 ;; esac +mkdir -p "$DEST" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT HUP INT TERM +sh "$SCRIPT_DIR/download-mihomo.sh" "$ARCH" "$TMP" +cp "$PANEL" "$TMP/3m-ui-bin" +chmod 0755 "$TMP/3m-ui-bin" +for SCRIPT in install.sh update.sh uninstall.sh 3m-ui.sh 3m-ui; do + cp "$SCRIPT_DIR/$SCRIPT" "$TMP/$SCRIPT" + chmod 0755 "$TMP/$SCRIPT" +done +printf '%s\n' "$VERSION" > "$TMP/VERSION" +printf '%s\n' "$REPOSITORY" > "$TMP/REPOSITORY" +cp "$ROOT/distribution/mihomo.env" "$TMP/mihomo.env" +cp "$ROOT/distribution/MIHOMO_LICENSE" "$TMP/MIHOMO_LICENSE" +cp "$ROOT/LICENSE" "$TMP/LICENSE" +printf 'Mihomo source and build scripts: https://github.com/MetaCubeX/mihomo/tree/%s\n' \ + "$(cat "$TMP/MIHOMO_VERSION")" > "$TMP/MIHOMO_SOURCE" +COPYFILE_DISABLE=1 tar -czf "$DEST/3m-ui-bundle-linux-$ARCH.tar.gz" -C "$TMP" \ + 3m-ui-bin mihomo MIHOMO_VERSION VERSION REPOSITORY mihomo.env LICENSE MIHOMO_LICENSE MIHOMO_SOURCE \ + install.sh update.sh uninstall.sh 3m-ui.sh 3m-ui diff --git a/scripts/download-mihomo.sh b/scripts/download-mihomo.sh new file mode 100755 index 0000000..a460931 --- /dev/null +++ b/scripts/download-mihomo.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# Download exactly the reviewed core. Never resolve a moving upstream latest. +set -eu +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +. "$SCRIPT_DIR/../distribution/mihomo.env" +ARCH=${1:?Usage: download-mihomo.sh amd64|arm64 OUTPUT_DIRECTORY} +DEST=${2:?Output directory is required} +case "$ARCH" in + amd64) ASSET=$MIHOMO_AMD64_ASSET; SHA256=$MIHOMO_AMD64_SHA256 ;; + arm64) ASSET=$MIHOMO_ARM64_ASSET; SHA256=$MIHOMO_ARM64_SHA256 ;; + *) echo "No reviewed Mihomo bundle for architecture: $ARCH" >&2; exit 1 ;; +esac +mkdir -p "$DEST" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT HUP INT TERM +curl --fail --location --silent --show-error --retry 3 --connect-timeout 15 --max-time 180 \ + "https://github.com/MetaCubeX/mihomo/releases/download/$MIHOMO_VERSION/$ASSET" \ + --output "$TMP/$ASSET" +if command -v sha256sum >/dev/null 2>&1; then + ACTUAL=$(sha256sum "$TMP/$ASSET" | cut -d ' ' -f 1) +else + ACTUAL=$(shasum -a 256 "$TMP/$ASSET" | cut -d ' ' -f 1) +fi +[ "$ACTUAL" = "$SHA256" ] || { echo "Mihomo checksum mismatch: $ASSET" >&2; exit 1; } +gzip -dc "$TMP/$ASSET" > "$DEST/mihomo" +chmod 0755 "$DEST/mihomo" +printf '%s\n' "$MIHOMO_VERSION" > "$DEST/MIHOMO_VERSION" diff --git a/scripts/install.sh b/scripts/install.sh index 1b91d27..d84d108 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,502 +1,329 @@ #!/usr/bin/env sh +# Standalone bootstrapper and installed lifecycle implementation. set -eu umask 077 -REPO="kazeyukiro/3m-ui" -BASE="/usr/local/lib/3m-ui" +# ROOT is useful for package builders and isolated lifecycle tests; normal installs use /. +ROOT="${THREE_M_UI_ROOT:-}" +BASE="$ROOT/usr/local/lib/3m-ui" +ENTRY="$ROOT/usr/local/bin/3m-ui" +CONFIG_DIR="$ROOT/etc/3m-ui" +DATA_DIR="${THREE_M_UI_DATA_DIR:-}" +if [ -z "$DATA_DIR" ] && [ -s "$BASE/DATA_DIRECTORY" ]; then DATA_DIR="$(cat "$BASE/DATA_DIRECTORY")"; fi +DATA_DIR="${DATA_DIR:-$ROOT/var/lib/3m-ui}" +LOG_DIR="$ROOT/var/log/3m-ui" +CONFIG_FILE="$CONFIG_DIR/config.yaml" APP_BIN="$BASE/3m-ui-bin" -VERSION_FILE="$BASE/VERSION" -MODE_FILE="$BASE/BUILD_MODE" -ENTRY="/usr/local/bin/3m-ui" -CONFIG_DIR="/etc/3m-ui" -DATA_DIR="/var/lib/3m-ui" -LOG_DIR="/var/log/3m-ui" -MIHOMO_BIN="/usr/local/bin/mihomo" -SERVICE_NAME="3m-ui" -YES=0 -INSTALL_MIHOMO=1 -# Official releases are pure-Go static binaries (portable on glibc + musl). -STATIC="${THREE_M_UI_STATIC:-1}" +MIHOMO_BIN="$BASE/mihomo" +SERVICE_NAME=3m-ui +REPO="${THREE_M_UI_REPO:-}" +CHANNEL="${THREE_M_UI_CHANNEL:-}" REQUESTED_VERSION="" -RELEASE_TAG="" -TMP_FILES="" +INSTALL_MIHOMO=1 +OPERATION=install +YES=0 +SNAPSHOT="" +TRANSACTION=0 +STOPPED=0 +WAS_RUNNING=0 +WORK="" say(){ printf '%s\n' "$*"; } err(){ say "Error: $*" >&2; exit 1; } command_exists(){ command -v "$1" >/dev/null 2>&1; } -need_root(){ [ "$(id -u)" -eq 0 ] || err "Please run this script as root."; } +usage(){ cat </dev/null || true -} -trap cleanup_tmp EXIT INT TERM - -track_tmp(){ - TMP_FILES="$TMP_FILES $1" -} - -usage(){ cat </dev/null 2>&1;; + *) return 1;; + esac +} +reload_service(){ [ "$INIT" != systemd ] || systemctl daemon-reload; } +stop_existing(){ + if [ -e "$UNIT" ] || service_active; then service_action stop; fi + STOPPED=1 } - -# Map uname -m → release artifact arch suffix (must match release.yml). arch(){ case "$(uname -m)" in - x86_64|amd64) echo amd64;; - aarch64|arm64) echo arm64;; - armv7l|armv7*) echo armv7;; - armv6l|armv6*) echo armv6;; - i386|i486|i586|i686|x86) echo 386;; - riscv64) echo riscv64;; - loongarch64|loong64) echo loong64;; - ppc64le) echo ppc64le;; - s390x) echo s390x;; - *) err "Unsupported CPU architecture: $(uname -m). Supported: x86_64 aarch64 armv7 armv6 i686 riscv64 loongarch64 ppc64le s390x";; + x86_64|amd64) echo amd64;; aarch64|arm64) echo arm64;; + armv7l|armv7*) echo armv7;; armv6l|armv6*) echo armv6;; + i386|i486|i586|i686|x86) echo 386;; riscv64) echo riscv64;; + loongarch64|loong64) echo loong64;; ppc64le) echo ppc64le;; s390x) echo s390x;; + *) err "Unsupported architecture: $(uname -m)";; esac } - -init_system(){ if [ -d /run/systemd/system ] && command_exists systemctl; then echo systemd; elif command_exists rc-service; then echo openrc; else echo unsupported; fi; } - install_deps(){ - missing="" - for x in curl ca-certificates gzip tar sed; do command_exists "$x" || missing="$missing $x"; done - [ -z "$missing" ] && return - case "$(os_id)" in - alpine) apk add --no-cache curl ca-certificates gzip tar sed coreutils;; - debian|ubuntu|linuxmint|raspbian|pop|elementary|zorin|kali|parrot) apt-get update && apt-get install -y curl ca-certificates gzip tar sed coreutils;; - fedora|rhel|centos|rocky|almalinux|oracle|amzn|ol) if command_exists dnf; then dnf install -y curl ca-certificates gzip tar sed coreutils; else yum install -y curl ca-certificates gzip tar sed coreutils; fi;; - arch|manjaro|endeavouros|garuda|artix) pacman -Sy --noconfirm curl ca-certificates gzip tar sed coreutils;; - opensuse*|sles) zypper --non-interactive install curl ca-certificates gzip tar sed coreutils;; - void) xbps-install -Sy curl ca-certificates gzip tar sed coreutils;; - gentoo) emerge --ask=n net-misc/curl app-arch/gzip app-arch/tar sys-apps/sed sys-apps/coreutils || err "emerge failed; install curl gzip tar sed coreutils manually";; - *) err "Cannot install dependencies automatically on $(os_id):$missing. Install curl ca-certificates gzip tar sed, then re-run.";; - esac + if command_exists curl && command_exists tar && command_exists gzip && + { command_exists sha256sum || command_exists shasum || command_exists openssl; }; then return; fi + if command_exists apk; then apk add --no-cache curl ca-certificates tar gzip coreutils + elif command_exists apt-get; then apt-get update && apt-get install -y curl ca-certificates tar gzip coreutils + elif command_exists dnf; then dnf install -y curl ca-certificates tar gzip coreutils + elif command_exists yum; then yum install -y curl ca-certificates tar gzip coreutils + elif command_exists pacman; then pacman -Sy --noconfirm curl ca-certificates tar gzip coreutils + elif command_exists zypper; then zypper --non-interactive install curl ca-certificates tar gzip coreutils + else err 'Install curl, CA certificates, tar, gzip and sha256sum before continuing.'; fi +} +download(){ curl -fLsS --retry 3 --retry-delay 1 --connect-timeout 10 --max-time 300 "$1" -o "$2"; } +file_sha256(){ + if command_exists sha256sum; then sha256sum "$1" | awk '{print $1}' + elif command_exists shasum; then shasum -a 256 "$1" | awk '{print $1}' + else openssl dgst -sha256 "$1" | awk '{print $NF}'; fi +} +source_settings(){ + [ -n "$REPO" ] || { [ ! -s "$BASE/REPOSITORY" ] || REPO="$(cat "$BASE/REPOSITORY")"; } + REPO="${REPO:-kazeyukiro/3m-ui}" + case "$REPO" in *[!a-zA-Z0-9._/-]*|/*|*/|*/*/*|*/../*|../*) err 'Invalid THREE_M_UI_REPO; expected owner/repository.';; esac + case "$REPO" in */*) :;; *) err 'Invalid THREE_M_UI_REPO; expected owner/repository.';; esac + [ -n "$CHANNEL" ] || { [ ! -s "$BASE/CHANNEL" ] || CHANNEL="$(cat "$BASE/CHANNEL")"; } + CHANNEL="${CHANNEL:-stable}" + [ "$REQUESTED_VERSION" != pre ] || CHANNEL=pre + [ "$CHANNEL" != prerelease ] || CHANNEL=pre + case "$CHANNEL" in stable|pre) :;; *) err 'THREE_M_UI_CHANNEL must be stable or pre.';; esac + if [ -n "$REQUESTED_VERSION" ]; then TAG="$REQUESTED_VERSION" + elif [ "$CHANNEL" = pre ]; then TAG=pre + else + # GitHub latest excludes prereleases. Never silently fall back to a development build. + location="$(curl -fLsSI --retry 3 --connect-timeout 10 --max-time 60 -o /dev/null -w '%{url_effective}' "https://github.com/$REPO/releases/latest")" || err 'No stable release available; choose a version or explicitly select THREE_M_UI_CHANNEL=pre.' + TAG="${location##*/}" + case "$TAG" in v[0-9]*) :;; *) err 'Unable to resolve a stable release; choose an explicit version.';; esac + fi + case "$TAG" in *[!a-zA-Z0-9._+-]*) err 'Invalid release tag.';; esac + RELEASE_URL="https://github.com/$REPO/releases/download/$TAG" +} +verify_asset(){ + expected="$(awk -v a="$1" '{ n=$2; sub(/^\*/,"",n); sub(/^\.\//,"",n); if(n==a) {print $1; exit} }' "$WORK/SHA256SUMS")" + [ -n "$expected" ] || err "$1 is missing from SHA256SUMS." + actual="$(file_sha256 "$2")" + [ "$actual" = "$expected" ] || err "Checksum mismatch for $1." + say "Checksum OK: $1" +} +verify_provenance(){ + [ "${THREE_M_UI_VERIFY_COSIGN:-0}" = 1 ] || return 0 + command_exists cosign || err 'THREE_M_UI_VERIFY_COSIGN=1 requires cosign.' + download "$RELEASE_URL/SHA256SUMS.pem" "$WORK/SHA256SUMS.pem" + download "$RELEASE_URL/SHA256SUMS.sig" "$WORK/SHA256SUMS.sig" + workflow=release.yml + [ "$TAG" != pre ] || workflow=release-pre.yml + for ref in "refs/tags/$TAG" refs/heads/main refs/heads/test; do + if cosign verify-blob --certificate "$WORK/SHA256SUMS.pem" --signature "$WORK/SHA256SUMS.sig" \ + --certificate-identity "https://github.com/$REPO/.github/workflows/$workflow@$ref" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com "$WORK/SHA256SUMS" >/dev/null 2>&1; then return; fi + done + err 'Release signature verification failed.' +} +prepare_release(){ + mkdir -p "$WORK/release" + download "$RELEASE_URL/SHA256SUMS" "$WORK/SHA256SUMS" + verify_provenance + cpu="$(arch)" + if [ "$INSTALL_MIHOMO" = 1 ]; then + case "$cpu" in amd64|arm64) :;; *) err "Complete bundles are available for amd64/arm64; use --no-mihomo for $cpu.";; esac + asset="3m-ui-bundle-linux-$cpu.tar.gz" + say "Downloading $REPO $TAG ($asset)..." + download "$RELEASE_URL/$asset" "$WORK/bundle.tar.gz" + verify_asset "$asset" "$WORK/bundle.tar.gz" + # Bundles contain only these flat, regular files. Reject unexpected names/links. + tar -tzf "$WORK/bundle.tar.gz" > "$WORK/members" + while IFS= read -r member; do + case "${member#./}" in 3m-ui-bin|mihomo|MIHOMO_VERSION|VERSION|REPOSITORY|mihomo.env|LICENSE|MIHOMO_LICENSE|MIHOMO_SOURCE|install.sh|update.sh|uninstall.sh|3m-ui.sh|3m-ui) :;; *) err "Unexpected bundle member: $member";; esac + done < "$WORK/members" + tar -tvzf "$WORK/bundle.tar.gz" | awk 'substr($0,1,1)!="-" { bad=1 } END {exit bad}' || err 'Bundle contains non-regular files.' + tar -xzf "$WORK/bundle.tar.gz" -C "$WORK/release" + for member in 3m-ui-bin mihomo MIHOMO_VERSION VERSION REPOSITORY install.sh update.sh uninstall.sh 3m-ui.sh 3m-ui; do + [ -s "$WORK/release/$member" ] || err "Incomplete bundle: $member missing." + done + [ "$(cat "$WORK/release/REPOSITORY")" = "$REPO" ] || err 'Bundle repository does not match the selected release source.' + else + asset="3m-ui-linux-$cpu" + download "$RELEASE_URL/$asset" "$WORK/release/3m-ui-bin" + verify_asset "$asset" "$WORK/release/3m-ui-bin" + for member in install.sh update.sh uninstall.sh 3m-ui.sh 3m-ui; do + download "$RELEASE_URL/$member" "$WORK/release/$member" + verify_asset "$member" "$WORK/release/$member" + done + fi + for member in install.sh update.sh uninstall.sh 3m-ui.sh 3m-ui; do sh -n "$WORK/release/$member"; done + chmod 0755 "$WORK/release/3m-ui-bin" + "$WORK/release/3m-ui-bin" --version >/dev/null + if [ "$INSTALL_MIHOMO" = 1 ]; then chmod 0755 "$WORK/release/mihomo"; "$WORK/release/mihomo" -v >/dev/null; fi } -download(){ if command_exists curl; then curl -fL --retry 3 --retry-delay 1 --connect-timeout 10 --max-time 300 "$1" -o "$2"; else wget -qO "$2" "$1"; fi; } -download_quiet(){ if command_exists curl; then curl -fsSL --retry 2 --retry-delay 1 --connect-timeout 10 --max-time 300 "$1" -o "$2"; else wget -qO "$2" "$1"; fi; } +validate_storage(){ + [ -f "$CONFIG_FILE" ] || return 0 + # Use the new binary to inspect old settings read-only, before any downtime or + # migration. Refuse to claim a complete backup of shared external directories. + inspection_bin="$1" + (cd "$DATA_DIR" && THREE_M_UI_CONFIG="$CONFIG_FILE" "$inspection_bin" storage-paths) > "$WORK/storage-paths" + while IFS= read -r path; do + case "$path" in "$CONFIG_DIR"|"$CONFIG_DIR"/*|"$DATA_DIR"|"$DATA_DIR"/*) :;; + *) err "Storage outside the managed configuration/data directories: $path. Move it into $CONFIG_DIR or $DATA_DIR before using automatic install/update/backup.";; + esac + case "$path" in "$DATA_DIR/backups"|"$DATA_DIR/backups"/*) err "Active storage cannot live in the backup directory: $path";; esac + done < "$WORK/storage-paths" +} -latest_tag(){ - # test-branch scripts: prefer fixed rolling Pre-release tag "pre", then other prereleases, then /releases/latest. - base="${1:-https://github.com/$REPO}" - repo_path="${base#https://github.com/}" - repo_path="${repo_path%/}" - # 1) Rolling pre tag (single mutable Pre-release) - if [ "$repo_path" = "kazeyukiro/3m-ui" ] || [ "$repo_path" = "${REPO:-kazeyukiro/3m-ui}" ]; then - code="$(curl -fsSLI -o /dev/null -w '%{http_code}' "https://github.com/${repo_path}/releases/download/pre/SHA256SUMS" 2>/dev/null || true)" - if [ "$code" = "200" ]; then - printf '%s' "pre" - return 0 - fi - # API fallback for tag existence - if curl -fsSL -H 'Accept: application/vnd.github+json' \ - "https://api.github.com/repos/${repo_path}/releases/tags/pre" >/dev/null 2>&1; then - printf '%s' "pre" - return 0 - fi +# Snapshots cover configuration, encryption keys, SQLite/WAL, certificates, all +# app data, managed binaries/helpers, the entrypoint and service definition. +create_snapshot(){ + mkdir -p "$WORK/snapshot" "$DATA_DIR/backups" + for spec in "base:$BASE" "config:$CONFIG_DIR" "entry:$ENTRY" "service:$UNIT"; do + name="${spec%%:*}"; path="${spec#*:}" + if [ -e "$path" ] || [ -L "$path" ]; then cp -a "$path" "$WORK/snapshot/$name"; fi + done + if [ -d "$DATA_DIR" ]; then + mkdir -p "$WORK/snapshot/data" + # Intermediate archive propagates tar errors rather than hiding a failing pipeline. + tar -cf "$WORK/data.tar" --exclude='./backups' -C "$DATA_DIR" . + tar -xf "$WORK/data.tar" -C "$WORK/snapshot/data" fi - api="https://api.github.com/repos/${repo_path}/releases?per_page=30" - body="$(curl -fsSL -H 'Accept: application/vnd.github+json' "$api" 2>/dev/null)" || body="" - tag="" - if [ -n "$body" ]; then - tag="$(printf '%s' "$body" | tr '\n' ' ' | sed 's/},[[:space:]]*{/\n/g' | while IFS= read -r block; do - echo "$block" | grep -q '"draft"[[:space:]]*:[[:space:]]*true' && continue - echo "$block" | grep -q '"prerelease"[[:space:]]*:[[:space:]]*true' || continue - echo "$block" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1 - break - done)" - if [ -z "$tag" ]; then - tag="$(printf '%s' "$body" | tr '\n' ' ' | sed 's/},[[:space:]]*{/\n/g' | while IFS= read -r block; do - echo "$block" | grep -q '"draft"[[:space:]]*:[[:space:]]*true' && continue - echo "$block" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1 - break - done)" - fi - fi - if [ -z "$tag" ]; then - tag="$(curl -fsSLI -o /dev/null -w '%{url_effective}' "$base/releases/latest" 2>/dev/null | sed 's#.*/##; s/[[:space:][:cntrl:]]*$//')" || true - case "$tag" in - http*|HTML|*latest*|"") tag="$(curl -fsSL "$base/releases/latest" 2>/dev/null | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)" || true;; + printf '%s\n' '3m-ui-snapshot-v1' > "$WORK/snapshot/FORMAT" + printf '%s\n' "$BASE" "$CONFIG_DIR" "$DATA_DIR" "$ENTRY" "$UNIT" > "$WORK/snapshot/PATHS" + printf '%s\n' "$WAS_RUNNING" > "$WORK/snapshot/RUNNING" + enabled=0 + case "$INIT" in + systemd) systemctl is-enabled --quiet "$SERVICE_NAME" && enabled=1 || true;; + openrc) [ ! -e "$ROOT/etc/runlevels/default/$SERVICE_NAME" ] || enabled=1;; + esac + printf '%s\n' "$enabled" > "$WORK/snapshot/ENABLED" + SNAPSHOT="$DATA_DIR/backups/$(date -u +%Y%m%dT%H%M%SZ)-$$.tar.gz" + tar -czf "$SNAPSHOT.tmp" -C "$WORK/snapshot" . + mv "$SNAPSHOT.tmp" "$SNAPSHOT" + say "Snapshot: $SNAPSHOT" +} +restore_files(){ + restore_dir="$1" + # Undo enablement created by a failed first installation before removing its unit. + if [ "$(cat "$restore_dir/ENABLED")" = 0 ]; then + case "$INIT" in + systemd) systemctl disable "$SERVICE_NAME" >/dev/null 2>&1 || true;; + openrc) rc-update del "$SERVICE_NAME" default >/dev/null 2>&1 || true;; esac fi - printf '%s' "$tag" -} - - - -random_hex(){ dd if=/dev/urandom bs=1 count="${1:-32}" 2>/dev/null | od -An -tx1 | tr -d ' \n'; } - -# Compute SHA-256 of a file; empty string if no hasher available. -file_sha256(){ - f="$1" - if command_exists sha256sum; then - sha256sum "$f" | awk '{print $1}' - elif command_exists shasum; then - shasum -a 256 "$f" | awk '{print $1}' - elif command_exists openssl; then - openssl dgst -sha256 "$f" | awk '{print $NF}' - else - printf '' - fi -} - -# Verify file against release SHA256SUMS. Fail-closed (C-2): any integrity -# failure aborts the install. Set THREE_M_UI_INSECURE=1 to bypass verification -# when GitHub is unavailable (NOT recommended; prints a loud warning). -verify_release_sha256(){ - tag="$1" - asset="$2" - path="$3" - # sha256 tooling is now a hard dependency for release verification. - if ! command_exists sha256sum && ! command_exists shasum && ! command_exists openssl; then - err "No sha256 tool available; cannot verify $asset. Install coreutils/sha256sum (or openssl) and re-run." - fi - sums_tmp="$(mktemp)" - track_tmp "$sums_tmp" - if ! download "https://github.com/$REPO/releases/download/${tag}/SHA256SUMS" "$sums_tmp" 2>/dev/null; then - if [ "${THREE_M_UI_INSECURE:-0}" = "1" ]; then - say "WARNING: THREE_M_UI_INSECURE=1 set; SHA256SUMS not published for $tag; skipping checksum verification (NOT RECOMMENDED)." - return 0 - fi - err "SHA256SUMS not published for $tag; cannot verify $asset. Set THREE_M_UI_INSECURE=1 to bypass (NOT recommended)." - fi - # SHA256SUMS may list "./3m-ui-linux-amd64" or "3m-ui-linux-amd64" - expected="$(awk -v a="$asset" '{ n=$2; sub(/^\.\//,"",n); if (n==a) { print $1; exit } }' "$sums_tmp")" - if [ -z "$expected" ]; then - if [ "${THREE_M_UI_INSECURE:-0}" = "1" ]; then - say "WARNING: THREE_M_UI_INSECURE=1 set; $asset not listed in SHA256SUMS; skipping verification (NOT RECOMMENDED)." - return 0 - fi - err "$asset not listed in SHA256SUMS for $tag; cannot verify. Set THREE_M_UI_INSECURE=1 to bypass (NOT recommended)." - fi - actual="$(file_sha256 "$path")" - if [ "$actual" != "$expected" ]; then - err "Checksum mismatch for $asset (expected $expected, got $actual)" - fi - say "Checksum OK: $asset" -} - -# Verify the SHA256SUMS file itself with cosign keyless OIDC (authenticity). -# OPT-IN: only runs when THREE_M_UI_VERIFY_COSIGN=1 AND cosign is installed. -# SHA256SUMS integrity is already enforced by verify_release_sha256 (fail-closed). -# This adds PROVENANCE: proves the sums file was produced by this repo's -# release.yml workflow at the given tag (not a CDN compromise). -verify_release_cosign(){ - tag="$1"; repo="$2" - if [ "${THREE_M_UI_VERIFY_COSIGN:-0}" != "1" ]; then - return 0 - fi - if ! command_exists cosign; then - say "Warning: THREE_M_UI_VERIFY_COSIGN=1 set but cosign not installed; skipping signature verification." - say " Install cosign: https://github.com/sigstore/cosign/releases" - return 0 - fi - pem_tmp="$(mktemp)"; sig_tmp="$(mktemp)"; sums_tmp="$(mktemp)" - track_tmp "$pem_tmp" "$sig_tmp" "$sums_tmp" - base="https://github.com/${repo}/releases/download/${tag}" - if ! download "$base/SHA256SUMS" "$sums_tmp" 2>/dev/null; then - say "Warning: SHA256SUMS not available for $tag; cannot verify cosign signature." - return 0 - fi - if ! download "$base/SHA256SUMS.sig" "$sig_tmp" 2>/dev/null || \ - ! download "$base/SHA256SUMS.pem" "$pem_tmp" 2>/dev/null; then - say "Warning: cosign signature artifacts (SHA256SUMS.sig / .pem) not published for $tag." - say " Releases before cosign signing was enabled are unsigned." - return 0 - fi - # Try both tag-ref (normal tag-push release) and main-branch (workflow_dispatch - # back-fill) identities, since older releases may have been signed via dispatch. - base_id="https://github.com/${repo}/.github/workflows/release.yml" - for identity in "${base_id}@refs/tags/${tag}" "${base_id}@refs/heads/main"; do - if cosign verify-blob \ - --certificate "$pem_tmp" \ - --signature "$sig_tmp" \ - --certificate-identity "$identity" \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - "$sums_tmp" >/dev/null 2>&1; then - say "Cosign signature OK: SHA256SUMS verified against $identity" - return 0 + # Keep backup archives available across rollback and manual restores. + mkdir -p "$DATA_DIR" || return 1 + for path in "$DATA_DIR"/* "$DATA_DIR"/.[!.]* "$DATA_DIR"/..?*; do + [ -e "$path" ] || [ -L "$path" ] || continue + [ "$path" = "$DATA_DIR/backups" ] || rm -rf "$path" || return 1 + done + [ ! -d "$restore_dir/data" ] || cp -a "$restore_dir/data/." "$DATA_DIR/" || return 1 + for spec in "base:$BASE" "config:$CONFIG_DIR" "entry:$ENTRY" "service:$UNIT"; do + name="${spec%%:*}"; path="${spec#*:}" + rm -rf "$path" || return 1 + if [ -e "$restore_dir/$name" ] || [ -L "$restore_dir/$name" ]; then + mkdir -p "$(dirname "$path")" || return 1 + cp -a "$restore_dir/$name" "$path" || return 1 fi done - err "Cosign signature verification FAILED for $tag. The SHA256SUMS file may have been tampered with or produced by an untrusted source. Refusing to continue (set THREE_M_UI_INSECURE=1 to bypass, NOT recommended)." -} - -# Query the GitHub Release API for the official SHA-256 digest of an asset. -# GitHub computes this server-side when the asset is uploaded, so it is -# authoritative even when the upstream project does not publish a SHA256SUMS -# file (Mihomo does not). Falls back to a best-effort check if the API is -# unreachable. -github_asset_digest(){ - repo="$1"; tag="$2"; asset="$3" - api_url="https://api.github.com/repos/${repo}/releases/tags/${tag}" - # GitHub's API may be rate-limited; prefer a curl+grep fallback over jq. - if command_exists curl; then - curl -fsSL "$api_url" 2>/dev/null | \ - awk -v want="$asset" ' - /"name"[[:space:]]*:/ { name=$0; sub(/.*"name"[[:space:]]*:[[:space:]]*"/,"",name); sub(/".*/,"",name) } - /"digest"[[:space:]]*:/ { - if (name == want) { - d=$0; sub(/.*"digest"[[:space:]]*:[[:space:]]*"/,"",d); sub(/".*/,"",d); print d; exit - } - }' - else - wget -qO- "$api_url" 2>/dev/null | \ - awk -v want="$asset" ' - /"name"[[:space:]]*:/ { name=$0; sub(/.*"name"[[:space:]]*:[[:space:]]*"/,"",name); sub(/".*/,"",name) } - /"digest"[[:space:]]*:/ { - if (name == want) { - d=$0; sub(/.*"digest"[[:space:]]*:[[:space:]]*"/,"",d); sub(/".*/,"",d); print d; exit - } - }' + reload_service || return 1 + if [ "$(cat "$restore_dir/ENABLED")" = 1 ]; then + case "$INIT" in + systemd) systemctl enable "$SERVICE_NAME" >/dev/null;; + openrc) rc-update add "$SERVICE_NAME" default >/dev/null;; + esac fi } - -# Verify the Mihomo .gz asset against the official SHA-256 digest published by -# GitHub's Release API (C-3). Mihomo does not ship a SHA256SUMS file, so we -# rely on GitHub's server-side digest instead. Fail-closed unless -# THREE_M_UI_INSECURE=1. -verify_mihomo_sha256(){ - tag="$1" - asset="$2" - gzpath="$3" - if ! command_exists sha256sum && ! command_exists shasum && ! command_exists openssl; then - err "No sha256 tool available; cannot verify Mihomo $asset. Install coreutils/sha256sum (or openssl) and re-run." - fi - say "Querying GitHub Release API for official digest of $asset..." - digest="$(github_asset_digest MetaCubeX/mihomo "$tag" "$asset")" - if [ -z "$digest" ]; then - say "Warning: could not retrieve digest for $asset from GitHub Release API (tag $tag)." >&2 - say " This is usually a rate-limit; retry later or set THREE_M_UI_INSECURE=1." >&2 - return 1 - fi - case "$digest" in - sha256:*) expected="${digest#sha256:}" ;; - *) say "Warning: unexpected digest format from GitHub API: $digest" >&2; return 1 ;; - esac - actual="$(file_sha256 "$gzpath")" - if [ -z "$actual" ]; then - err "Cannot compute SHA-256 of $asset; no sha256 tool available." - fi - if [ "$actual" != "$expected" ]; then - err "Checksum mismatch for Mihomo $asset (expected $expected, got $actual)" - fi - say "Checksum OK: Mihomo $asset (verified via GitHub Release API digest)" -} - -write_config(){ - mkdir -p "$CONFIG_DIR" "$DATA_DIR/mihomo" "$LOG_DIR" - # NAT-friendly: PANEL_PORT / THREE_M_UI_PORT (default 8080), optional PANEL_LISTEN / PUBLIC_URL - panel_port="${PANEL_PORT:-${THREE_M_UI_PORT:-8080}}" - case "$panel_port" in - ''|*[!0-9]*) panel_port=8080;; - esac - if [ "$panel_port" -lt 1 ] || [ "$panel_port" -gt 65535 ]; then panel_port=8080; fi - panel_listen="${PANEL_LISTEN:-${THREE_M_UI_LISTEN:-}}" - public_url="${PUBLIC_URL:-${THREE_M_UI_PUBLIC_URL:-}}" - - if [ -f "$CONFIG_DIR/config.yaml" ]; then - # Config already exists — only update port/listen/public_url if the user - # explicitly set the env vars. This preserves existing customizations - # (admin password already changed, etc.) while still allowing re-install - # with a different port via PANEL_PORT=9000 bash install.sh. - chmod 0600 "$CONFIG_DIR/config.yaml" - if [ -n "${PANEL_PORT:-${THREE_M_UI_PORT:-}}" ]; then - say "Updating panel port to ${panel_port} in existing config..." - sed -i "s/^ port: .*/ port: ${panel_port}/" "$CONFIG_DIR/config.yaml" +cleanup(){ + result=$? + trap - EXIT HUP INT TERM + if [ "$TRANSACTION" = 1 ]; then + say 'Operation failed; restoring the previous installation and data.' >&2 + service_action stop >/dev/null 2>&1 || true + if [ -d "$WORK/snapshot" ] && restore_files "$WORK/snapshot"; then + if [ "$WAS_RUNNING" = 1 ]; then service_action start || say 'Could not restart the restored service; inspect 3m-ui logs.' >&2; fi + else + say "Automatic restore failed. Recovery snapshot: $SNAPSHOT" >&2 fi - if [ -n "${PANEL_LISTEN:-${THREE_M_UI_LISTEN:-}}" ]; then - sed -i "s/^ listen: .*/ listen: \"${panel_listen}\"/" "$CONFIG_DIR/config.yaml" - fi - if [ -n "${PUBLIC_URL:-${THREE_M_UI_PUBLIC_URL:-}}" ]; then - sed -i "s|^ public_url: .*| public_url: \"${public_url}\"|" "$CONFIG_DIR/config.yaml" - fi - return + [ "$result" != 0 ] || result=1 fi - - # First-time install — write full config with random JWT/credential keys. - cat > "$CONFIG_DIR/config.yaml" <&2 + fi + [ -z "$WORK" ] || rm -rf "$WORK" + exit "$result" } +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' HUP TERM -mihomo_asset(){ - case "$(uname -m)" in - x86_64|amd64) echo mihomo-linux-amd64-compatible;; - aarch64|arm64) echo mihomo-linux-arm64;; - armv7l|armv7*) echo mihomo-linux-armv7;; - armv6l|armv6*) echo mihomo-linux-armv6;; - i386|i486|i586|i686|x86) echo mihomo-linux-386;; - riscv64) echo mihomo-linux-riscv64;; - loongarch64|loong64) echo mihomo-linux-loong64-abi2.0;; - *) echo "";; - esac -} - -install_mihomo(){ - [ "$INSTALL_MIHOMO" -eq 1 ] || { say "Mihomo installation skipped."; return; } - if [ -x "$MIHOMO_BIN" ]; then say "Existing Mihomo detected: $MIHOMO_BIN"; return; fi - asset="$(mihomo_asset)" - if [ -z "$asset" ]; then - say "Warning: no official Mihomo binary for $(uname -m). Install Mihomo manually or re-run with --no-mihomo." +write_service(){ + # Keep operator overrides, capabilities and enablement on an existing install. + # Older systemd syscall filters cannot run the current pure-Go SQLite build. + if [ -f "$WORK/snapshot/service" ]; then + if [ "$INIT" = systemd ]; then + if grep -q '^SystemCallFilter=' "$UNIT"; then + sed '/^SystemCallFilter=/d; /^PrivateDevices=/d; /^MemoryDenyWriteExecute=/d; /^RestrictNamespaces=/d' "$UNIT" > "$WORK/service" + install -m 0644 "$WORK/service" "$UNIT" + fi + systemctl daemon-reload + fi return 0 fi - tag="$(latest_tag https://github.com/MetaCubeX/mihomo)"; [ -n "$tag" ] || err "Unable to determine latest Mihomo release." - tmp="$(mktemp)"; gz="$tmp.gz" - track_tmp "$tmp"; track_tmp "$gz" - gzname="${asset}-${tag}.gz" - url="https://github.com/MetaCubeX/mihomo/releases/download/${tag}/${gzname}" - say "Downloading Mihomo $tag..."; download "$url" "$gz" - # Verify the .gz checksum if possible. Mihomo does not publish SHA256SUMS, - # so we query the GitHub Release API for the official per-asset digest - # (GitHub computes this server-side when the asset is uploaded). - # Anonymous GitHub API is rate-limited (60/hour/IP); if rate-limited or - # unreachable, fall back to best-effort (warn, do NOT abort) because: - # - Our own 3m-ui binary is already fail-closed verified above. - # - The Mihomo download still goes over HTTPS to GitHub (transport integrity). - # - The ELF smoke-test below catches gross corruption. - # Users who want strict Mihomo verification can set THREE_M_UI_VERIFY_MIHOMO=1 - # to make verification failures abort (requires GitHub API to be reachable). - if ! verify_mihomo_sha256 "$tag" "$gzname" "$gz"; then - if [ "${THREE_M_UI_VERIFY_MIHOMO:-0}" = "1" ]; then - err "Mihomo checksum verification failed (THREE_M_UI_VERIFY_MIHOMO=1 set). Set THREE_M_UI_INSECURE=1 to bypass (NOT recommended)." - else - say "Warning: could not verify Mihomo checksum; continuing (download is over HTTPS, ELF will be smoke-tested)." - say " Set THREE_M_UI_VERIFY_MIHOMO=1 to enforce verification (requires GitHub API access)." - fi - fi - gzip -dc "$gz" > "$tmp"; chmod 0755 "$tmp" - # Smoke-test the decompressed binary without executing it as root (C-5). - if command_exists file; then - file "$tmp" | grep -q "ELF" || err "Downloaded Mihomo is not a valid ELF binary." - fi - install -m 0755 "$tmp" "$MIHOMO_BIN" - rm -f "$tmp" "$gz" -} - -install_release_asset(){ - tag="$1"; file="$2"; destination="$3" - tmp="$(mktemp)"; track_tmp "$tmp" - # H-1: do NOT fall back to raw.githubusercontent.com — releases MUST contain - # all helper scripts. A missing release asset indicates an incomplete release. - if ! download "https://github.com/$REPO/releases/download/${tag}/${file}" "$tmp" 2>/dev/null; then - err "Release $tag does not contain $file. Refusing to fall back to raw.githubusercontent.com for supply-chain safety; use a complete release." - fi - verify_release_sha256 "$tag" "$file" "$tmp" - install -m 0755 "$tmp" "$destination" - rm -f "$tmp" -} - -install_helpers(){ - tag="$1" - mkdir -p "$BASE" - install_release_asset "$tag" 3m-ui.sh "$BASE/3m-ui.sh" - install_release_asset "$tag" install.sh "$BASE/install.sh" - install_release_asset "$tag" update.sh "$BASE/update.sh" - install_release_asset "$tag" uninstall.sh "$BASE/uninstall.sh" - install_release_asset "$tag" 3m-ui "$ENTRY" -} - -install_panel(){ - RELEASE_TAG="${REQUESTED_VERSION:-$(latest_tag https://github.com/$REPO)}" - [ -n "$RELEASE_TAG" ] || err "Unable to determine latest 3m-ui release." - # Official releases are pure-Go static binaries named 3m-ui-linux-. - # Fall back to legacy *-static name for older tags if needed. - cpu="$(arch)" - asset="3m-ui-linux-${cpu}" - tmp="$(mktemp)"; track_tmp "$tmp" - url="https://github.com/$REPO/releases/download/${RELEASE_TAG}/${asset}" - say "Downloading 3m-ui $RELEASE_TAG ($asset)..." - if ! download "$url" "$tmp" 2>/dev/null; then - asset="3m-ui-linux-${cpu}-static" - url="https://github.com/$REPO/releases/download/${RELEASE_TAG}/${asset}" - say "Retrying legacy asset name: $asset" - download "$url" "$tmp" - fi - verify_release_sha256 "$RELEASE_TAG" "$asset" "$tmp" - # Provenance: verify the release's SHA256SUMS was signed by this repo's - # release.yml workflow (cosign keyless). OPT-IN via THREE_M_UI_VERIFY_COSIGN=1. - verify_release_cosign "$RELEASE_TAG" "$REPO" - chmod 0755 "$tmp" - # Smoke-test the binary is a Linux ELF — do NOT execute it as root (C-5). - # verify_release_sha256 already attests to integrity. - if command_exists file; then - file "$tmp" | grep -q "ELF" || err "Downloaded 3m-ui is not a valid ELF binary." - fi - install -m 0755 "$tmp" "$APP_BIN" - printf '%s\n' "$RELEASE_TAG" > "$VERSION_FILE" - printf '%s\n' "$([ "$STATIC" = "1" ] && echo static || echo dynamic)" > "$MODE_FILE" - chmod 0600 "$VERSION_FILE" "$MODE_FILE" - rm -f "$tmp" -} - -write_systemd(){ cat > /etc/systemd/system/$SERVICE_NAME.service < "$UNIT" </dev/null; systemctl restart "$SERVICE_NAME"; } - -write_openrc(){ cat > /etc/init.d/$SERVICE_NAME </dev/null + else + cat > "$UNIT" </dev/null 2>&1 || true; rc-service "$SERVICE_NAME" restart; } - -install_service(){ case "$(init_system)" in systemd) write_systemd;; openrc) write_openrc;; *) err "Unsupported init system. Supported: systemd and OpenRC.";; esac; } - -main(){ - need_root; install_deps - mkdir -p "$BASE" "$CONFIG_DIR" "$DATA_DIR/mihomo" "$LOG_DIR" - if [ -t 0 ] && [ "$YES" -ne 1 ]; then - say "3m-ui Installer"; say "OS: $(os_id) Architecture: $(arch) Init: $(init_system)" - printf 'Install Mihomo Core too? [Y/n] '; read -r answer || true - case "$answer" in n|N|no|NO) INSTALL_MIHOMO=0;; esac +UNITFILE + chmod 0755 "$UNIT" + rc-update add "$SERVICE_NAME" default >/dev/null fi - write_config - if [ -x "$APP_BIN" ]; then - case "$(init_system)" in systemd) systemctl stop "$SERVICE_NAME" 2>/dev/null || true;; openrc) rc-service "$SERVICE_NAME" stop 2>/dev/null || true;; esac - fi - install_panel - install_mihomo - install_helpers "$RELEASE_TAG" - install_service - say "" - say "3m-ui installed successfully." - # Automation: write install result (default admin/admin unchanged by policy). - result_file="${THREE_M_UI_INSTALL_RESULT:-/etc/3m-ui/install-result.env}" - mkdir -p "$(dirname "$result_file")" 2>/dev/null || true - { - echo "PANEL_PORT=${panel_port:-8080}" - echo "INSTALL_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date)" - echo "ADMIN_USER=admin" - echo "ADMIN_PASSWORD_NOTE=unchanged_default_must_change_on_first_login" - } >"$result_file" 2>/dev/null || true - if [ "${THREE_M_UI_NONINTERACTIVE:-}" = "1" ] || [ ! -t 0 ]; then - say "Non-interactive install result: $result_file" - fi - say "Command: 3m-ui (interactive menu: start/stop/logs/port/BBR/geo/…)" - panel_port="${PANEL_PORT:-${THREE_M_UI_PORT:-8080}}" - # Prefer config file port if present - if [ -f "$CONFIG_DIR/config.yaml" ]; then - cfg_port=$(awk '/^ port:/ {print $2; exit}' "$CONFIG_DIR/config.yaml" 2>/dev/null || true) - [ -n "$cfg_port" ] && panel_port="$cfg_port" - fi - say "Panel port: ${panel_port}" - say "Local: http://127.0.0.1:${panel_port}/" - ips=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1) - if [ -z "$ips" ]; then - ips=$(hostname -I 2>/dev/null || true) - fi - for ip in $ips; do - [ -n "$ip" ] && say "Panel: http://${ip}:${panel_port}/" +} +wait_healthy(){ + attempt=0 + while [ "$attempt" -lt 30 ]; do + if service_active && (cd "$DATA_DIR" && THREE_M_UI_CONFIG="$CONFIG_FILE" "$APP_BIN" healthcheck) >/dev/null 2>&1; then return 0; fi + attempt=$((attempt + 1)) + sleep 1 done - say "Custom port: PANEL_PORT=8443 curl ... | bash or: 3m-ui (menu → 修改面板端口)" - say "Initial administrator: admin / admin (unchanged). You MUST change the password on first login." - say "Subscription formats auto-detect User-Agent; override with ?target=clash|v2ray|singbox" + err 'Service health check failed after 30 attempts.' +} +manual_restore(){ + [ -f "$SNAPSHOT" ] || err 'Usage: 3m-ui restore /absolute/path/to/snapshot.tar.gz [--yes]' + mkdir -p "$WORK/restore" + # Only archives produced by this installer are accepted; never source metadata. + tar -tzf "$SNAPSHOT" > "$WORK/members" + while IFS= read -r member; do + case "$member" in /*|../*|*/../*|*/..) err 'Unsafe snapshot member.';; esac + case "${member#./}" in ''|FORMAT|PATHS|RUNNING|ENABLED|base|base/*|config|config/*|data|data/*|entry|service) :;; *) err 'Unrecognized snapshot layout.';; esac + done < "$WORK/members" + tar -xzf "$SNAPSHOT" -C "$WORK/restore" + [ "$(cat "$WORK/restore/FORMAT")" = '3m-ui-snapshot-v1' ] || err 'Unsupported snapshot format.' + printf '%s\n' "$BASE" "$CONFIG_DIR" "$DATA_DIR" "$ENTRY" "$UNIT" > "$WORK/expected-paths" + cmp -s "$WORK/expected-paths" "$WORK/restore/PATHS" || err 'Snapshot paths differ from this installation; restore on the original host paths.' + if [ "$YES" != 1 ]; then + [ -t 0 ] || err 'Non-interactive restore requires --yes.' + printf 'Replace the installation and data with this snapshot? [y/N] ' + read -r answer + case "$answer" in y|Y|yes|YES) :;; *) exit 0;; esac + fi + validate_storage "$APP_BIN" + service_active && WAS_RUNNING=1 || true + stop_existing + # Keep a snapshot of the current installation so a failed restore is reversible. + create_snapshot + TRANSACTION=1 + restore_files "$WORK/restore" + service_action start + wait_healthy + TRANSACTION=0 + STOPPED=0 + say 'Snapshot restored and service is healthy.' +} +main(){ + [ "$(id -u)" -eq 0 ] || err 'Please run as root.' + [ "$(uname -s)" = Linux ] || err 'Native installation requires Linux.' + # Avoid ambiguous paths in generated service definitions and snapshot metadata. + for path in "$ROOT" "$BASE" "$CONFIG_DIR" "$DATA_DIR"; do + case "$path" in *[[:space:]]*|*/../*|*/..|/|/etc|/usr|/var) err "Unsupported installation path: $path";; esac + done + case "$DATA_DIR" in /*) :;; *) err 'THREE_M_UI_DATA_DIR must be an absolute path.';; esac + INIT="$(init_system)" + [ "$INIT" != unsupported ] || err 'A systemd or OpenRC Linux host is required.' + if [ "$INIT" = systemd ]; then UNIT="$ROOT/etc/systemd/system/$SERVICE_NAME.service"; else UNIT="$ROOT/etc/init.d/$SERVICE_NAME"; fi + WORK="$(mktemp -d)" + case "$OPERATION" in + restore) manual_restore; return;; + backup) + [ -x "$APP_BIN" ] || err '3m-ui is not installed.' + validate_storage "$APP_BIN" + service_active && WAS_RUNNING=1 || true + stop_existing + # Cleanup restarts the previous service if creating the snapshot fails. + create_snapshot + if [ "$WAS_RUNNING" = 1 ]; then service_action start; fi + STOPPED=0 + return;; + update) [ -x "$APP_BIN" ] || err '3m-ui is not installed.';; + esac + install_deps + source_settings + prepare_release + validate_storage "$WORK/release/3m-ui-bin" + service_active && WAS_RUNNING=1 || true + stop_existing + create_snapshot + TRANSACTION=1 + mkdir -p "$BASE" "$(dirname "$ENTRY")" "$CONFIG_DIR" "$DATA_DIR/mihomo" "$LOG_DIR" + # Retain existing cores and custom files. Only replace release-owned files. + for member in 3m-ui-bin install.sh update.sh uninstall.sh 3m-ui.sh 3m-ui; do + install -m 0755 "$WORK/release/$member" "$BASE/$member" + done + if [ "$INSTALL_MIHOMO" = 1 ]; then + install -m 0755 "$WORK/release/mihomo" "$MIHOMO_BIN" + cp "$WORK/release/MIHOMO_VERSION" "$BASE/MIHOMO_VERSION" + for member in mihomo.env LICENSE MIHOMO_LICENSE MIHOMO_SOURCE; do + if [ -f "$WORK/release/$member" ]; then install -m 0644 "$WORK/release/$member" "$BASE/$member"; fi + done + fi + install -m 0755 "$WORK/release/3m-ui" "$ENTRY" + printf '%s\n' "$TAG" > "$BASE/VERSION" + printf '%s\n' static > "$BASE/BUILD_MODE" + printf '%s\n' "$REPO" > "$BASE/REPOSITORY" + printf '%s\n' "$CHANNEL" > "$BASE/CHANNEL" + printf '%s\n' "$DATA_DIR" > "$BASE/DATA_DIRECTORY" + # Existing YAML/DB settings are preserved by init, including external core paths. + (cd "$DATA_DIR" && THREE_M_UI_CONFIG="$CONFIG_FILE" THREE_M_UI_DATA_DIR="$DATA_DIR" \ + THREE_M_UI_MIHOMO_BINARY="${THREE_M_UI_MIHOMO_BINARY:-$MIHOMO_BIN}" "$APP_BIN" init) + write_service + service_action start + wait_healthy + TRANSACTION=0 + STOPPED=0 + say "3m-ui $TAG is ready ($REPO, channel $CHANNEL)." + say 'New installations print a one-time administrator password above. Use 3m-ui reset-admin if it is lost.' + say "Configuration: $CONFIG_FILE" + say "Management: 3m-ui Backup: $SNAPSHOT" } main "$@" diff --git a/scripts/release-metadata.py b/scripts/release-metadata.py new file mode 100644 index 0000000..0b8c464 --- /dev/null +++ b/scripts/release-metadata.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Validate immutable release tags and emit GitHub Actions metadata.""" +import os +import re +import sys + + +def metadata(tag: str) -> dict[str, str]: + match = re.fullmatch( + r"v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" + r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?" + r"(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?", + tag, + ) + if not match: + raise ValueError(f"Release tag must be SemVer, for example v1.2.3 or v1.3.0-rc.1: {tag}") + prerelease = match.group(4) + if prerelease and any(p.isdecimal() and len(p) > 1 and p[0] == "0" for p in prerelease.split(".")): + raise ValueError("Numeric prerelease identifiers cannot have leading zeroes") + # OCI tag syntax excludes '+'. Double underscore cannot occur in SemVer and + # gives build metadata a distinct spelling without colliding with '-rc'. + image_tag = tag.replace("+", "__") + if len(image_tag) > 128: + raise ValueError("Release tag is too long for an OCI image tag") + return { + "tag": tag, + "prerelease": str(bool(prerelease)).lower(), + "latest": str(not prerelease).lower(), + "image_tag": image_tag, + } + + +if __name__ == "__main__": + try: + values = metadata(sys.argv[1]) + except (IndexError, ValueError) as exc: + sys.exit(str(exc)) + output = "".join(f"{key}={value}\n" for key, value in values.items()) + if os.environ.get("GITHUB_OUTPUT"): + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as stream: + stream.write(output) + else: + print(output, end="") diff --git a/scripts/smoke-runtime.py b/scripts/smoke-runtime.py new file mode 100644 index 0000000..a900a81 --- /dev/null +++ b/scripts/smoke-runtime.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Exercise a real panel/core pair on loopback, with isolated disposable data. + +Usage: python3 scripts/smoke-runtime.py PANEL_BINARY MIHOMO_BINARY +The core must be in an allowed installation directory (/opt/ or the managed +/usr/local/lib/3m-ui/). No existing service, database or public endpoint is used. +""" + +import contextlib +import hashlib +import http.client +import http.server +import json +import os +from pathlib import Path +import re +import secrets +import shutil +import signal +import socket +import subprocess +import sys +import tempfile +import threading +import time +import urllib.error +import urllib.request + + +def free_port(): + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def stop_process(process): + if process is not None and process.poll() is None: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=5) + + +def main(): + panel, core = map(lambda p: str(Path(p).resolve()), sys.argv[1:]) + processes = [] + with tempfile.TemporaryDirectory(prefix="3m-runtime-") as tmp: + root = Path(tmp) + data = root / "data" + config = root / "config.yaml" + port, listener_port, proxy_port, controller_port = [free_port() for _ in range(4)] + env = os.environ.copy() + env.update(THREE_M_UI_CONFIG=str(config), THREE_M_UI_DATA_DIR=str(data), + THREE_M_UI_MIHOMO_BINARY=core, THREE_M_UI_PORT=str(port), + THREE_M_UI_LISTEN="127.0.0.1", THREE_M_UI_ADMIN_USERNAME="admin", + THREE_M_UI_ADMIN_PASSWORD="", + THREE_M_UI_MIHOMO_CONTROLLER=f"127.0.0.1:{controller_port}") + initialized = subprocess.run([panel, "init"], env=env, capture_output=True, text=True, check=True) + password = re.search(r"^Initial password: (.+)$", initialized.stdout, re.M).group(1) + fingerprint = hashlib.sha256(config.read_bytes()).hexdigest() + again = subprocess.run([panel, "init"], env=env, capture_output=True, text=True, check=True) + assert "Initial password:" not in again.stdout, "Repeated init reset credentials" + assert hashlib.sha256(config.read_bytes()).hexdigest() == fingerprint, "Repeated init changed keys" + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + token = None + + def api(route, body=None): + request = urllib.request.Request(f"http://127.0.0.1:{port}/api/v1/{route}", + data=None if body is None else json.dumps(body).encode()) + request.add_header("Content-Type", "application/json") + if token: + request.add_header("Authorization", f"Bearer {token}") + try: + with opener.open(request, timeout=10) as response: + return json.load(response) + except urllib.error.HTTPError as error: + raise AssertionError(f"{route}: HTTP {error.code}: {error.read().decode()}") from None + + def start_panel(log): + process = subprocess.Popen([panel], env=env, cwd=root, stdout=log, stderr=log, start_new_session=True) + processes.append(process) + for _ in range(60): + if process.poll() is not None: + raise AssertionError("Panel exited before readiness") + result = subprocess.run([panel, "healthcheck"], env=env, capture_output=True) + if result.returncode == 0: + return process + time.sleep(0.25) + raise AssertionError("Panel readiness timed out") + + with contextlib.ExitStack() as stack: + stack.callback(lambda: [stop_process(process) for process in reversed(processes)]) + def stop_core(): + with contextlib.suppress(Exception): + api("mihomo/stop", {}) + stack.callback(stop_core) + log = stack.enter_context((root / "panel.log").open("w+")) + running = start_panel(log) + token = api("auth/login", {"username": "admin", "password": password})["token"] + password = "smoke-" + secrets.token_urlsafe(20) + original = re.search(r"^Initial password: (.+)$", initialized.stdout, re.M).group(1) + api("auth/password", {"current_password": original, "new_password": password}) + token = api("auth/login", {"username": "admin", "password": password})["token"] + ss_password = secrets.token_urlsafe(24) + listener = api("listeners", { + "name": "runtime-smoke", "protocol": "shadowsocks", "port": str(listener_port), + "bind_address": "127.0.0.1", "enabled": True, + "config": json.dumps({"cipher": "aes-128-gcm", "password": ss_password}), + }) + assert api("mihomo/status")["running"], "Bundled core did not start" + # Persist real listener certificates as well as the Shadowsocks node. + api("listeners", {"name": "certificate-smoke", "protocol": "trojan", "port": str(free_port()), + "bind_address": "127.0.0.1", "enabled": True, "config": "{}"}) + certificates = {str(p.relative_to(data)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in (data / "listener-certs").rglob("*") if p.is_file()} + assert certificates, "Listener certificates were not persisted" + + class Target(http.server.BaseHTTPRequestHandler): + def do_GET(self): + body = b"3m-ui-real-proxy-ok" + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args): + pass + + target = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Target) + threading.Thread(target=target.serve_forever, daemon=True).start() + stack.callback(target.server_close) + stack.callback(target.shutdown) + client_config = root / "client.json" + client_config.write_text(json.dumps({ + "mixed-port": proxy_port, "bind-address": "127.0.0.1", "allow-lan": False, + "dns": {"enable": False}, "log-level": "silent", + "proxies": [{"name": "server", "type": "ss", "server": "127.0.0.1", "port": listener_port, + "cipher": "aes-128-gcm", "password": ss_password}], + "rules": ["MATCH,server"], + })) + client_log = stack.enter_context((root / "client.log").open("w+")) + client = subprocess.Popen([core, "-d", str(root / "client"), "-f", str(client_config)], + stdout=client_log, stderr=client_log, start_new_session=True) + processes.append(client) + + def check_connection(): + for _ in range(40): + try: + connection = http.client.HTTPConnection("127.0.0.1", proxy_port, timeout=2) + connection.request("GET", f"http://127.0.0.1:{target.server_port}/") + response = connection.getresponse() + content = response.read() + connection.close() + if response.status == 200 and content == b"3m-ui-real-proxy-ok": + return + except OSError: + pass + time.sleep(0.25) + raise AssertionError("Actual HTTP -> Shadowsocks -> target request failed") + + check_connection() + # Use the existing authenticated stop API to stop the independently + # supervised core before ending the panel process. + api("mihomo/stop", {}) + stop_process(running) + snapshot = root / "snapshot" + shutil.copytree(data, snapshot) + running = start_panel(log) + token = api("auth/login", {"username": "admin", "password": password})["token"] + assert any(item["id"] == listener["id"] for item in api("listeners")), "Restart lost node" + check_connection() + api("mihomo/stop", {}) + stop_process(running) + shutil.rmtree(data) + shutil.copytree(snapshot, data) + running = start_panel(log) + token = api("auth/login", {"username": "admin", "password": password})["token"] + check_connection() + assert hashlib.sha256(config.read_bytes()).hexdigest() == fingerprint, "Restart changed keys" + for name, expected in certificates.items(): + assert hashlib.sha256((data / name).read_bytes()).hexdigest() == expected, "Restore changed TLS identity" + api("mihomo/stop", {}) + stop_process(running) + print("PASS: init, login/change-password, real Shadowsocks connection, restart, data restore and TLS identity") + + +if __name__ == "__main__": + if len(sys.argv) != 3: + raise SystemExit(__doc__) + main() diff --git a/scripts/tests/test_distribution.py b/scripts/tests/test_distribution.py new file mode 100644 index 0000000..c779e65 --- /dev/null +++ b/scripts/tests/test_distribution.py @@ -0,0 +1,56 @@ +"""Release channel and pinned download failure regression tests (no network).""" +import importlib.util +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location("release_metadata", ROOT / "scripts/release-metadata.py") +metadata = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(metadata) + + +class DistributionTests(unittest.TestCase): + def test_stable_and_candidate_channels(self): + stable = metadata.metadata("v1.2.3") + self.assertEqual((stable["latest"], stable["prerelease"]), ("true", "false")) + candidate = metadata.metadata("v1.3.0-rc.1") + self.assertEqual((candidate["latest"], candidate["prerelease"]), ("false", "true")) + + def test_build_metadata_does_not_collide_with_prerelease_image(self): + build = metadata.metadata("v1.2.3+rc.1") + candidate = metadata.metadata("v1.2.3-rc.1") + self.assertEqual(build["latest"], "true") + self.assertNotEqual(build["image_tag"], candidate["image_tag"]) + self.assertNotIn("+", build["image_tag"]) + + def test_invalid_and_rolling_tags_cannot_be_formal_releases(self): + for tag in ("pre", "v1.2", "v01.2.3", "v1.2.3-01", "v1.2.3-", "v1.2.3\nlatest=true"): + with self.subTest(tag=tag), self.assertRaises(ValueError): + metadata.metadata(tag) + + def test_changed_upstream_asset_is_rejected_before_installing_core(self): + with tempfile.TemporaryDirectory() as tmp: + directory = Path(tmp) + fake_curl = directory / "curl" + fake_curl.write_text( + '#!/bin/sh\nwhile [ "$#" -gt 0 ]; do\n' + ' if [ "$1" = --output ]; then shift; printf tampered > "$1"; exit; fi\n' + ' shift\ndone\nexit 1\n', encoding="utf-8" + ) + fake_curl.chmod(0o755) + dest = directory / "output" + env = dict(os.environ, PATH=f"{directory}:{os.environ['PATH']}") + result = subprocess.run( + ["sh", str(ROOT / "scripts/download-mihomo.sh"), "amd64", str(dest)], + env=env, capture_output=True, text=True, check=False, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("checksum mismatch", result.stderr) + self.assertFalse((dest / "mihomo").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_lifecycle.py b/scripts/tests/test_lifecycle.py new file mode 100644 index 0000000..ec98c33 --- /dev/null +++ b/scripts/tests/test_lifecycle.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +"""Exercise installation/update/restore entirely in temporary directories. + +Network, init system, root identity, binaries and sleep are mocked. No host +service, root directory, release or proxy is modified by these tests. +""" +import hashlib +import io +import os +from pathlib import Path +import shutil +import subprocess +import tarfile +import tempfile +import unittest + +SCRIPTS = Path(__file__).resolve().parents[1] + +PANEL = '''#!/bin/sh +set -eu +case "${1:-}" in + --version) echo "3m-ui @VERSION@";; + storage-paths) + if [ -n "${TEST_EXTERNAL_STORAGE:-}" ]; then echo "$TEST_EXTERNAL_STORAGE"; else echo "$THREE_M_UI_CONFIG"; fi;; + init) + mkdir -p "$(dirname "$THREE_M_UI_CONFIG")" "$THREE_M_UI_DATA_DIR" + if [ ! -f "$THREE_M_UI_CONFIG" ]; then + echo "port: 8080" > "$THREE_M_UI_CONFIG" + echo "old-db" > "$THREE_M_UI_DATA_DIR/3m-ui.db" + echo "Initial password: random-one-time-test-password" + fi + if [ "@VERSION@" = v2.0.0-badinit ]; then + echo corrupted > "$THREE_M_UI_CONFIG" + echo migrated > "$THREE_M_UI_DATA_DIR/3m-ui.db" + echo new > "$THREE_M_UI_DATA_DIR/created-by-migration" + exit 1 + fi;; + healthcheck) [ "@VERSION@" != v2.0.0-badhealth ];; + *) exit 2;; +esac +''' + +CURL = '''#!/usr/bin/env python3 +import os, pathlib, sys, shutil +args=sys.argv[1:] +url=next(a for a in args if a.startswith('https://')) +with open(os.environ['TEST_EVENTS'], 'a') as f: f.write('download '+url+'\\n') +if url.endswith('/releases/latest'): + print(url[:-len('/releases/latest')]+'/releases/tag/'+os.environ.get('TEST_LATEST','v1.0.0'),end='') +else: + path=url.split('/releases/download/',1)[1] + source=pathlib.Path(os.environ['TEST_RELEASES'])/path + if not source.is_file(): sys.exit(22) + shutil.copyfile(source,args[args.index('-o')+1]) +''' + +SYSTEMCTL = '''#!/usr/bin/env python3 +import os,pathlib,sys +root=pathlib.Path(os.environ['THREE_M_UI_ROOT']) +a=sys.argv[1] +active=root/'running' +enabled=root/'enabled' +with open(os.environ['TEST_EVENTS'],'a') as f: f.write('service '+a+'\\n') +if a=='is-active': sys.exit(0 if active.exists() else 3) +if a=='is-enabled': sys.exit(0 if enabled.exists() else 1) +if a=='start': + version=(root/'usr/local/lib/3m-ui/VERSION').read_text().strip() + if version=='v2.0.0-badstart': sys.exit(1) + active.touch() +if a=='stop': active.unlink(missing_ok=True) +if a=='enable': enabled.touch() +if a=='disable': enabled.unlink(missing_ok=True) +''' + + +class LifecycleTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix='3m-lifecycle-') + self.directory = Path(self.tmp.name) + self.root = self.directory / 'root' + self.root.mkdir() + (self.root / 'run/systemd/system').mkdir(parents=True) + self.release_root = self.directory / 'releases' + self.release_root.mkdir() + self.mock = self.directory / 'mock-bin' + self.mock.mkdir() + self.events = self.directory / 'events' + self.events.touch() + self.env = os.environ.copy() + self.env.update(THREE_M_UI_ROOT=str(self.root), + THREE_M_UI_REPO='example/3m-ui', + TEST_EVENTS=str(self.events), TEST_RELEASES=str(self.release_root), + PATH=str(self.mock) + os.pathsep + self.env['PATH']) + for name, source in { + 'curl': CURL, 'systemctl': SYSTEMCTL, + 'id': '#!/bin/sh\necho 0\n', + 'uname': '#!/bin/sh\ncase "$1" in -m) echo x86_64;; -s) echo Linux;; esac\n', + 'sleep': '#!/bin/sh\nexit 0\n', + }.items(): + self.executable(self.mock / name, source) + self.make_release('v1.0.0') + + def tearDown(self): + self.tmp.cleanup() + + @property + def base(self): return self.root / 'usr/local/lib/3m-ui' + @property + def config(self): return self.root / 'etc/3m-ui' + @property + def data(self): return self.root / 'var/lib/3m-ui' + + def executable(self, path, source): + path.write_text(source) + path.chmod(0o755) + + def make_release(self, version): + destination = self.release_root / version + destination.mkdir() + panel = PANEL.replace('@VERSION@', version).encode() + members = { + '3m-ui-bin': panel, 'mihomo': b'#!/bin/sh\necho Mihomo test\n', + 'VERSION': version.encode(), 'REPOSITORY': b'example/3m-ui', + 'MIHOMO_VERSION': b'v1.19.0', 'mihomo.env': b'MIHOMO_VERSION=v1.19.0', + 'LICENSE': b'Panel license', 'MIHOMO_LICENSE': b'Core license', + 'MIHOMO_SOURCE': b'https://github.com/MetaCubeX/mihomo/tree/v1.19.0', + } + members.update({name: (SCRIPTS / name).read_bytes() for name in + ['install.sh', 'update.sh', 'uninstall.sh', '3m-ui', '3m-ui.sh']}) + archive = destination / '3m-ui-bundle-linux-amd64.tar.gz' + with tarfile.open(archive, 'w:gz') as tar: + for name, content in members.items(): + info = tarfile.TarInfo(name) + info.size = len(content) + info.mode = 0o755 + tar.addfile(info, io.BytesIO(content)) + for name in ['install.sh', 'update.sh', 'uninstall.sh', '3m-ui', '3m-ui.sh']: + (destination / name).write_bytes(members[name]) + (destination / '3m-ui-linux-amd64').write_bytes(panel) + (destination / 'SHA256SUMS').write_text(''.join( + hashlib.sha256(path.read_bytes()).hexdigest() + ' ' + path.name + '\n' + for path in sorted(destination.iterdir()))) + + def run_script(self, name='install.sh', *args, success=True, **env): + run_env = self.env | env + result = subprocess.run(['sh', str(SCRIPTS / name), *args], env=run_env, + capture_output=True, text=True) + if success: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + else: + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + return result + + def install(self): return self.run_script('install.sh', '--yes') + + def test_default_stable_complete_install(self): + result = self.install() + self.assertEqual((self.base / 'VERSION').read_text().strip(), 'v1.0.0') + self.assertEqual((self.base / 'CHANNEL').read_text().strip(), 'stable') + self.assertEqual((self.base / 'REPOSITORY').read_text().strip(), 'example/3m-ui') + self.assertTrue((self.base / 'mihomo').is_file()) + self.assertTrue((self.root / 'running').is_file()) + self.assertIn('Initial password:', result.stdout) + for directory in (self.base, self.config, self.data): + for file in directory.rglob('*'): + if file.is_file() and file.suffix != '.gz': + # The fake binary embeds the test fixture, so inspect actual state only. + if file.parent == self.config or file.name == '3m-ui.db': + self.assertNotIn(b'random-one-time-test-password', file.read_bytes()) + self.assertIn('/releases/latest', self.events.read_text()) + self.assertNotIn('/download/pre/', self.events.read_text()) + + def test_explicit_pre_channel_persists(self): + self.make_release('pre') + self.run_script('install.sh', 'pre', '--yes') + self.events.write_text('') + self.run_script('update.sh', '--yes', THREE_M_UI_REPO='') + self.assertEqual((self.base / 'CHANNEL').read_text().strip(), 'pre') + self.assertIn('https://github.com/example/3m-ui/releases/download/pre/', self.events.read_text()) + self.assertNotIn('/releases/latest', self.events.read_text()) + + def test_semver_build_metadata_can_be_installed(self): + self.make_release('v1.0.0+build.1') + self.run_script('install.sh', 'v1.0.0+build.1', '--yes') + self.assertEqual((self.base / 'VERSION').read_text().strip(), 'v1.0.0+build.1') + + def test_bad_checksum_keeps_service_and_every_file(self): + self.install() + old = (self.base / '3m-ui-bin').read_bytes() + self.make_release('v2.0.0') + (self.release_root / 'v2.0.0/3m-ui-bundle-linux-amd64.tar.gz').write_bytes(b'broken') + self.events.write_text('') + result = self.run_script('update.sh', 'v2.0.0', '--yes', success=False) + self.assertIn('Checksum mismatch', result.stderr) + self.assertNotIn('service stop', self.events.read_text()) + self.assertEqual((self.base / '3m-ui-bin').read_bytes(), old) + self.assertTrue((self.root / 'running').is_file()) + + def add_persistent_state(self): + (self.config / 'config.yaml').write_text('custom config and encryption keys') + (self.data / '3m-ui.db-wal').write_text('wal-state') + (self.data / 'certificates').mkdir(exist_ok=True) + (self.data / 'certificates/listener.key').write_text('private-key') + (self.base / 'custom').write_text('preserve-extra') + + def assert_persistent_state(self): + self.assertEqual((self.config / 'config.yaml').read_text(), 'custom config and encryption keys') + self.assertEqual((self.data / '3m-ui.db').read_text().strip(), 'old-db') + self.assertEqual((self.data / '3m-ui.db-wal').read_text(), 'wal-state') + self.assertEqual((self.data / 'certificates/listener.key').read_text(), 'private-key') + self.assertEqual((self.base / 'custom').read_text(), 'preserve-extra') + self.assertFalse((self.data / 'created-by-migration').exists()) + + def test_failed_init_restores_complete_state(self): + self.install() + self.add_persistent_state() + old_entry = (self.root / 'usr/local/bin/3m-ui').read_bytes() + self.make_release('v2.0.0-badinit') + self.events.write_text('') + self.run_script('update.sh', 'v2.0.0-badinit', '--yes', success=False) + self.assert_persistent_state() + self.assertEqual((self.base / 'VERSION').read_text().strip(), 'v1.0.0') + self.assertEqual((self.root / 'usr/local/bin/3m-ui').read_bytes(), old_entry) + self.assertTrue((self.root / 'running').is_file()) + events = self.events.read_text().splitlines() + self.assertLess(max(i for i, line in enumerate(events) if line.startswith('download ')), + next(i for i, line in enumerate(events) if line == 'service stop')) + + def test_failed_start_and_health_restore(self): + for version in ['v2.0.0-badstart', 'v2.0.0-badhealth']: + with self.subTest(version=version): + self.install() + self.add_persistent_state() + self.make_release(version) + self.run_script('update.sh', version, '--yes', success=False) + self.assert_persistent_state() + self.assertEqual((self.base / 'VERSION').read_text().strip(), 'v1.0.0') + self.assertTrue((self.root / 'running').is_file()) + + def test_backup_restore_and_retained_archives(self): + self.install() + self.add_persistent_state() + result = self.run_script('install.sh', '--backup') + snapshot = Path(next(line.removeprefix('Snapshot: ') for line in result.stdout.splitlines() if line.startswith('Snapshot: '))) + with tarfile.open(snapshot) as tar: + self.assertFalse(any('/backups/' in name for name in tar.getnames())) + self.assertIn('./data/3m-ui.db-wal', tar.getnames()) + (self.data / '3m-ui.db').write_text('changed') + (self.data / 'certificates/listener.key').unlink() + self.run_script('install.sh', '--restore', str(snapshot), '--yes') + self.assert_persistent_state() + self.assertTrue(snapshot.exists()) + self.assertTrue((self.root / 'running').is_file()) + + def test_external_storage_rejected_before_stop(self): + self.install() + self.make_release('v2.0.0') + self.events.write_text('') + result = self.run_script('update.sh', 'v2.0.0', success=False, TEST_EXTERNAL_STORAGE='/srv/shared/cert.pem') + self.assertIn('Storage outside', result.stderr) + self.assertNotIn('service stop', self.events.read_text()) + self.assertTrue((self.root / 'running').is_file()) + + def test_uninstall_keeps_data_config_external_core(self): + self.install() + self.add_persistent_state() + external = self.root / 'usr/local/bin/mihomo' + external.write_text('external-core') + self.run_script('uninstall.sh', '--yes') + self.assertTrue((self.config / 'config.yaml').is_file()) + self.assertTrue((self.data / 'certificates/listener.key').is_file()) + self.assertEqual(external.read_text(), 'external-core') + self.assertFalse(self.base.exists()) + self.run_script('uninstall.sh', '--yes', '--purge') + self.assertFalse(self.config.exists()) + self.assertFalse(self.data.exists()) + self.assertTrue(external.is_file()) + + def test_failed_first_install_removes_service_and_enablement(self): + self.make_release('v2.0.0-badstart') + self.run_script('install.sh', 'v2.0.0-badstart', '--yes', success=False) + self.assertFalse(self.base.exists()) + self.assertFalse((self.root / 'etc/systemd/system/3m-ui.service').exists()) + self.assertFalse((self.root / 'enabled').exists()) + self.assertFalse((self.root / 'running').exists()) + + def test_failed_snapshot_restarts_old_service(self): + self.install() + self.make_release('v2.0.0') + real_tar = shutil.which('tar') + self.executable(self.mock / 'tar', '#!/bin/sh\nif [ "$1" = -czf ]; then exit 1; fi\nexec "' + real_tar + '" "$@"\n') + self.run_script('update.sh', 'v2.0.0', '--yes', success=False) + self.assertEqual((self.base / 'VERSION').read_text().strip(), 'v1.0.0') + self.assertTrue((self.root / 'running').exists()) + + def test_custom_data_directory_persists_for_updates(self): + custom = self.root / 'srv/3m-ui-data' + self.run_script('install.sh', '--yes', THREE_M_UI_DATA_DIR=str(custom)) + self.make_release('v2.0.0') + self.run_script('update.sh', 'v2.0.0', '--yes') + self.assertEqual((self.base / 'DATA_DIRECTORY').read_text().strip(), str(custom)) + self.assertTrue((custom / '3m-ui.db').is_file()) + self.assertFalse((self.data / '3m-ui.db').exists()) + + def test_latest_never_falls_back_to_pre(self): + self.make_release('pre') + result = self.run_script('install.sh', '--yes', success=False, TEST_LATEST='pre') + self.assertIn('Unable to resolve a stable release', result.stderr) + self.assertNotIn('/download/pre/', self.events.read_text()) + self.assertFalse(self.base.exists()) + + def test_no_mihomo_is_explicit_standalone(self): + self.run_script('install.sh', '--no-mihomo', '--yes') + self.assertTrue((self.base / '3m-ui-bin').is_file()) + self.assertFalse((self.base / 'mihomo').exists()) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index fa83bd7..d8c32cb 100644 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -2,13 +2,15 @@ set -eu umask 077 -BASE="/usr/local/lib/3m-ui" +ROOT="${THREE_M_UI_ROOT:-}" +BASE="$ROOT/usr/local/lib/3m-ui" APP_BIN="$BASE/3m-ui-bin" -ENTRY="/usr/local/bin/3m-ui" -CONFIG_DIR="/etc/3m-ui" -DATA_DIR="/var/lib/3m-ui" -LOG_DIR="/var/log/3m-ui" -MIHOMO_BIN="/usr/local/bin/mihomo" +ENTRY="$ROOT/usr/local/bin/3m-ui" +CONFIG_DIR="$ROOT/etc/3m-ui" +DATA_DIR="${THREE_M_UI_DATA_DIR:-}" +if [ -z "$DATA_DIR" ] && [ -s "$BASE/DATA_DIRECTORY" ]; then DATA_DIR="$(cat "$BASE/DATA_DIRECTORY")"; fi +DATA_DIR="${DATA_DIR:-$ROOT/var/lib/3m-ui}" +LOG_DIR="$ROOT/var/log/3m-ui" SERVICE_NAME="3m-ui" PURGE=0 YES=0 @@ -22,8 +24,11 @@ for arg in "$@"; do esac done +for path in "$BASE" "$CONFIG_DIR" "$DATA_DIR" "$LOG_DIR"; do + case "$path" in /|/etc|/usr|/var|*/../*|*/..|*[[:space:]]*) echo "Unsafe installation path: $path" >&2; exit 1;; esac +done [ "$(id -u)" -eq 0 ] || { echo "Error: please run as root." >&2; exit 1; } -init_system(){ if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then echo systemd; elif command -v rc-service >/dev/null 2>&1; then echo openrc; else echo unsupported; fi; } +init_system(){ if [ -d "$ROOT/run/systemd/system" ] && command -v systemctl >/dev/null 2>&1; then echo systemd; elif command -v rc-service >/dev/null 2>&1; then echo openrc; else echo unsupported; fi; } if [ "$YES" -ne 1 ]; then [ -t 0 ] || { echo "Non-interactive uninstall requires --yes." >&2; exit 1; } @@ -32,9 +37,9 @@ if [ "$YES" -ne 1 ]; then echo " Application: $APP_BIN" echo " Config: $CONFIG_DIR" if [ "$PURGE" -eq 1 ]; then - echo " Data: $DATA_DIR [WILL BE DELETED — irreversible]" + echo " Config and data: $CONFIG_DIR, $DATA_DIR [WILL BE DELETED — irreversible]" else - echo " Data: $DATA_DIR [KEPT]" + echo " Config and data: $CONFIG_DIR, $DATA_DIR [KEPT]" fi printf 'Continue? [y/N] '; read -r answer case "$answer" in y|Y|yes|YES) ;; *) echo "Aborted."; exit 0;; esac @@ -47,26 +52,27 @@ fi case "$(init_system)" in systemd) - systemctl disable --now "$SERVICE_NAME" >/dev/null 2>&1 || true - rm -f "/etc/systemd/system/$SERVICE_NAME.service" + if [ -f "$ROOT/etc/systemd/system/$SERVICE_NAME.service" ]; then systemctl stop "$SERVICE_NAME"; fi + systemctl disable "$SERVICE_NAME" >/dev/null 2>&1 || true + rm -f "$ROOT/etc/systemd/system/$SERVICE_NAME.service" systemctl daemon-reload >/dev/null 2>&1 || true ;; openrc) - rc-service "$SERVICE_NAME" stop >/dev/null 2>&1 || true + if [ -f "$ROOT/etc/init.d/$SERVICE_NAME" ]; then rc-service "$SERVICE_NAME" stop; fi rc-update del "$SERVICE_NAME" default >/dev/null 2>&1 || true - rm -f "/etc/init.d/$SERVICE_NAME" + rm -f "$ROOT/etc/init.d/$SERVICE_NAME" ;; esac rm -f "$ENTRY" -rm -rf "$BASE" "$CONFIG_DIR" "$LOG_DIR" +rm -rf "$BASE" "$LOG_DIR" if [ "$PURGE" -eq 1 ]; then - rm -rf "$DATA_DIR" + rm -rf "$DATA_DIR" "$CONFIG_DIR" echo "3m-ui uninstalled and application data purged." else - echo "3m-ui uninstalled. Persistent data kept at: $DATA_DIR" + echo "3m-ui uninstalled. Data kept at: $DATA_DIR; configuration, keys and certificates kept at: $CONFIG_DIR" fi -echo "Mihomo was left untouched: $MIHOMO_BIN" +echo "The managed Mihomo binary was removed with 3m-ui; external cores were left untouched." echo "Done." diff --git a/scripts/update.sh b/scripts/update.sh index 6ef8eb1..6d7a1f2 100644 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -1,499 +1,6 @@ #!/usr/bin/env sh set -eu -umask 077 - -REPO="kazeyukiro/3m-ui" -BASE="/usr/local/lib/3m-ui" -APP_BIN="$BASE/3m-ui-bin" -VERSION_FILE="$BASE/VERSION" -MODE_FILE="$BASE/BUILD_MODE" -ENTRY="/usr/local/bin/3m-ui" -CONFIG_DIR="/etc/3m-ui" -DATA_DIR="/var/lib/3m-ui" -LOG_DIR="/var/log/3m-ui" -MIHOMO_BIN="/usr/local/bin/mihomo" -SERVICE_NAME="3m-ui" -UPDATE_MIHOMO=1 -REQUESTED_VERSION="" -BACKUP_KEEP=5 - -for arg in "$@"; do - case "$arg" in - -y|--yes) :;; - --no-mihomo) UPDATE_MIHOMO=0;; - -h|--help) - printf '%s\n' 'Usage: update.sh [VERSION] [--yes] [--no-mihomo]' - printf '%s\n' ' VERSION optional tag such as v1.0.0 (default: newest pre-release (test channel))' - exit 0 - ;; - v[0-9]*|manual-[0-9]*|test-[0-9a-zA-Z._-]*|pre) - [ -z "$REQUESTED_VERSION" ] || { echo "Error: only one version may be specified." >&2; exit 1; } - REQUESTED_VERSION="$arg" - ;; - *) printf '%s\n' "Unknown option: $arg" >&2; exit 2;; - esac -done - -[ "$(id -u)" -eq 0 ] || { echo "Error: please run as root." >&2; exit 1; } -[ -x "$APP_BIN" ] || { echo "Error: 3m-ui application is not installed at $APP_BIN" >&2; exit 1; } -command_exists(){ command -v "$1" >/dev/null 2>&1; } -arch(){ - case "$(uname -m)" in - x86_64|amd64) echo amd64;; - aarch64|arm64) echo arm64;; - armv7l|armv7*) echo armv7;; - armv6l|armv6*) echo armv6;; - i386|i486|i586|i686|x86) echo 386;; - riscv64) echo riscv64;; - loongarch64|loong64) echo loong64;; - ppc64le) echo ppc64le;; - s390x) echo s390x;; - *) echo "Error: unsupported architecture: $(uname -m)" >&2; exit 1;; - esac -} -init_system(){ if [ -d /run/systemd/system ] && command_exists systemctl; then echo systemd; elif command_exists rc-service; then echo openrc; else echo unsupported; fi; } -download(){ if command_exists curl; then curl -fL --retry 3 --retry-delay 1 --connect-timeout 10 --max-time 300 "$1" -o "$2"; else wget -qO "$2" "$1"; fi; } -download_quiet(){ if command_exists curl; then curl -fsSL --retry 2 --retry-delay 1 --connect-timeout 10 --max-time 300 "$1" -o "$2"; else wget -qO "$2" "$1"; fi; } -latest_tag(){ - # test-branch scripts: prefer fixed rolling Pre-release tag "pre", then other prereleases, then /releases/latest. - base="${1:-https://github.com/$REPO}" - repo_path="${base#https://github.com/}" - repo_path="${repo_path%/}" - # 1) Rolling pre tag (single mutable Pre-release) - if [ "$repo_path" = "kazeyukiro/3m-ui" ] || [ "$repo_path" = "${REPO:-kazeyukiro/3m-ui}" ]; then - code="$(curl -fsSLI -o /dev/null -w '%{http_code}' "https://github.com/${repo_path}/releases/download/pre/SHA256SUMS" 2>/dev/null || true)" - if [ "$code" = "200" ]; then - printf '%s' "pre" - return 0 - fi - # API fallback for tag existence - if curl -fsSL -H 'Accept: application/vnd.github+json' \ - "https://api.github.com/repos/${repo_path}/releases/tags/pre" >/dev/null 2>&1; then - printf '%s' "pre" - return 0 - fi - fi - api="https://api.github.com/repos/${repo_path}/releases?per_page=30" - body="$(curl -fsSL -H 'Accept: application/vnd.github+json' "$api" 2>/dev/null)" || body="" - tag="" - if [ -n "$body" ]; then - # Prefer non-prerelease (stable) first; only then fall back to any release. - tag="$(printf '%s' "$body" | tr '\n' ' ' | sed 's/},[[:space:]]*{/\n/g' | while IFS= read -r block; do - echo "$block" | grep -q '"draft"[[:space:]]*:[[:space:]]*true' && continue - echo "$block" | grep -q '"prerelease"[[:space:]]*:[[:space:]]*true' && continue - echo "$block" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1 - break - done)" - if [ -z "$tag" ]; then - tag="$(printf '%s' "$body" | tr '\n' ' ' | sed 's/},[[:space:]]*{/\n/g' | while IFS= read -r block; do - echo "$block" | grep -q '"draft"[[:space:]]*:[[:space:]]*true' && continue - echo "$block" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1 - break - done)" - fi - fi - if [ -z "$tag" ]; then - tag="$(curl -fsSLI -o /dev/null -w '%{url_effective}' "$base/releases/latest" 2>/dev/null | sed 's#.*/##; s/[[:space:][:cntrl:]]*$//')" || true - case "$tag" in - http*|HTML|*latest*|"") tag="$(curl -fsSL "$base/releases/latest" 2>/dev/null | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)" || true;; - esac - fi - printf '%s' "$tag" -} - - - -file_sha256(){ - f="$1" - if command_exists sha256sum; then - sha256sum "$f" | awk '{print $1}' - elif command_exists shasum; then - shasum -a 256 "$f" | awk '{print $1}' - elif command_exists openssl; then - openssl dgst -sha256 "$f" | awk '{print $NF}' - else - printf '' - fi -} - -# Verify file against release SHA256SUMS. Fail-closed (C-2): any integrity -# failure aborts the update. Set THREE_M_UI_INSECURE=1 to bypass verification -# when GitHub is unavailable (NOT recommended; prints a loud warning). -verify_release_sha256(){ - tag="$1" - asset="$2" - path="$3" - # sha256 tooling is now a hard dependency for release verification. - if ! command_exists sha256sum && ! command_exists shasum && ! command_exists openssl; then - echo "Error: no sha256 tool available; cannot verify $asset. Install coreutils/sha256sum (or openssl) and re-run." >&2 - exit 1 - fi - sums_tmp="$(mktemp)" - if ! download "https://github.com/$REPO/releases/download/${tag}/SHA256SUMS" "$sums_tmp" 2>/dev/null; then - rm -f "$sums_tmp" - if [ "${THREE_M_UI_INSECURE:-0}" = "1" ]; then - echo "WARNING: THREE_M_UI_INSECURE=1 set; SHA256SUMS not published for $tag; skipping checksum verification (NOT RECOMMENDED)." >&2 - return 0 - fi - echo "Error: SHA256SUMS not published for $tag; cannot verify $asset. Set THREE_M_UI_INSECURE=1 to bypass (NOT recommended)." >&2 - exit 1 - fi - # SHA256SUMS may list "./3m-ui-linux-amd64" or "3m-ui-linux-amd64" - expected="$(awk -v a="$asset" '{ n=$2; sub(/^\.\//,"",n); if (n==a) { print $1; exit } }' "$sums_tmp")" - if [ -z "$expected" ]; then - rm -f "$sums_tmp" - if [ "${THREE_M_UI_INSECURE:-0}" = "1" ]; then - echo "WARNING: THREE_M_UI_INSECURE=1 set; $asset not listed in SHA256SUMS; skipping verification (NOT RECOMMENDED)." >&2 - return 0 - fi - echo "Error: $asset not listed in SHA256SUMS for $tag; cannot verify. Set THREE_M_UI_INSECURE=1 to bypass (NOT recommended)." >&2 - exit 1 - fi - actual="$(file_sha256 "$path")" - rm -f "$sums_tmp" - if [ "$actual" != "$expected" ]; then - echo "Error: checksum mismatch for $asset (expected $expected, got $actual)" >&2 - exit 1 - fi - echo "Checksum OK: $asset" -} - -# Verify the SHA256SUMS file itself with cosign keyless OIDC (authenticity). -# OPT-IN: only runs when THREE_M_UI_VERIFY_COSIGN=1 AND cosign is installed. -verify_release_cosign(){ - tag="$1"; repo="$2" - if [ "${THREE_M_UI_VERIFY_COSIGN:-0}" != "1" ]; then - return 0 - fi - if ! command_exists cosign; then - echo "Warning: THREE_M_UI_VERIFY_COSIGN=1 set but cosign not installed; skipping signature verification." >&2 - return 0 - fi - pem_tmp="$(mktemp)"; sig_tmp="$(mktemp)"; sums_tmp="$(mktemp)" - base="https://github.com/${repo}/releases/download/${tag}" - if ! download "$base/SHA256SUMS" "$sums_tmp" 2>/dev/null; then - echo "Warning: SHA256SUMS not available for $tag; cannot verify cosign signature." >&2 - return 0 - fi - if ! download "$base/SHA256SUMS.sig" "$sig_tmp" 2>/dev/null || \ - ! download "$base/SHA256SUMS.pem" "$pem_tmp" 2>/dev/null; then - echo "Warning: cosign signature artifacts (SHA256SUMS.sig / .pem) not published for $tag." >&2 - return 0 - fi - base_id="https://github.com/${repo}/.github/workflows/release.yml" - for identity in "${base_id}@refs/tags/${tag}" "${base_id}@refs/heads/main"; do - if cosign verify-blob \ - --certificate "$pem_tmp" \ - --signature "$sig_tmp" \ - --certificate-identity "$identity" \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - "$sums_tmp" >/dev/null 2>&1; then - echo "Cosign signature OK: SHA256SUMS verified against $identity" - rm -f "$pem_tmp" "$sig_tmp" "$sums_tmp" - return 0 - fi - done - echo "Error: cosign signature verification FAILED for $tag. Refusing to continue (set THREE_M_UI_INSECURE=1 to bypass, NOT recommended)." >&2 - rm -f "$pem_tmp" "$sig_tmp" "$sums_tmp" - exit 1 -} - -# Verify the Mihomo .gz asset against the upstream SHA256SUMS published by -# GitHub Release API provides official SHA-256 digest for each asset (C-3). -# Mihomo does not publish its own SHA256SUMS, so we rely on GitHub's -# server-side digest instead. Fail-closed unless THREE_M_UI_INSECURE=1. -github_asset_digest(){ - repo="$1"; tag="$2"; asset="$3" - api_url="https://api.github.com/repos/${repo}/releases/tags/${tag}" - if command_exists curl; then - curl -fsSL "$api_url" 2>/dev/null | \ - awk -v want="$asset" ' - /"name"[[:space:]]*:/ { name=$0; sub(/.*"name"[[:space:]]*:[[:space:]]*"/,"",name); sub(/".*/,"",name) } - /"digest"[[:space:]]*:/ { - if (name == want) { - d=$0; sub(/.*"digest"[[:space:]]*:[[:space:]]*"/,"",d); sub(/".*/,"",d); print d; exit - } - }' - else - wget -qO- "$api_url" 2>/dev/null | \ - awk -v want="$asset" ' - /"name"[[:space:]]*:/ { name=$0; sub(/.*"name"[[:space:]]*:[[:space:]]*"/,"",name); sub(/".*/,"",name) } - /"digest"[[:space:]]*:/ { - if (name == want) { - d=$0; sub(/.*"digest"[[:space:]]*:[[:space:]]*"/,"",d); sub(/".*/,"",d); print d; exit - } - }' - fi -} - -verify_mihomo_sha256(){ - tag="$1" - asset="$2" - gzpath="$3" - if ! command_exists sha256sum && ! command_exists shasum && ! command_exists openssl; then - echo "Error: no sha256 tool available; cannot verify Mihomo $asset. Install coreutils/sha256sum (or openssl) and re-run." >&2 - exit 1 - fi - echo "Querying GitHub Release API for official digest of $asset..." - digest="$(github_asset_digest MetaCubeX/mihomo "$tag" "$asset")" - if [ -z "$digest" ]; then - echo "Warning: could not retrieve digest for $asset from GitHub Release API (tag $tag)." >&2 - echo " This is usually a rate-limit; retry later or set THREE_M_UI_INSECURE=1." >&2 - return 1 - fi - case "$digest" in - sha256:*) expected="${digest#sha256:}" ;; - *) echo "Warning: unexpected digest format from GitHub API: $digest" >&2; return 1 ;; - esac - actual="$(file_sha256 "$gzpath")" - if [ -z "$actual" ]; then - echo "Error: cannot compute SHA-256 of $asset; no sha256 tool available." >&2 - exit 1 - fi - if [ "$actual" != "$expected" ]; then - echo "Error: checksum mismatch for Mihomo $asset (expected $expected, got $actual)" >&2 - exit 1 - fi - echo "Checksum OK: Mihomo $asset (verified via GitHub Release API digest)" -} - -prune_backups(){ - dir="$DATA_DIR/backups" - [ -d "$dir" ] || return 0 - # Keep the newest BACKUP_KEEP directories; delete the rest (POSIX-ish). - # shellcheck disable=SC2012 - ls -1dt "$dir"/* 2>/dev/null | tail -n +$((BACKUP_KEEP + 1)) | while read -r old; do - rm -rf "$old" - done || true -} - - -# Drop seccomp filters that kill pure-Go/modernc builds (SIGSYS / status=31). -fix_systemd_unit(){ - unit="/etc/systemd/system/${SERVICE_NAME}.service" - [ -f "$unit" ] || return 0 - case "$(init_system)" in systemd) ;; *) return 0;; esac - if grep -q '^SystemCallFilter=' "$unit" 2>/dev/null; then - echo "Patching $unit: removing SystemCallFilter (prevents SIGSYS crash)" - # Portable: rewrite unit without SystemCallFilter / PrivateDevices lines - tmp="$(mktemp)" - grep -v -E '^(SystemCallFilter=|PrivateDevices=|MemoryDenyWriteExecute=|RestrictNamespaces=)' "$unit" > "$tmp" || true - if [ -s "$tmp" ]; then - install -m 0644 "$tmp" "$unit" - systemctl daemon-reload || true - fi - rm -f "$tmp" - fi -} - -stop(){ case "$(init_system)" in systemd) systemctl stop "$SERVICE_NAME" 2>/dev/null || true;; openrc) rc-service "$SERVICE_NAME" stop 2>/dev/null || true;; esac; } -start(){ case "$(init_system)" in systemd) systemctl start "$SERVICE_NAME";; openrc) rc-service "$SERVICE_NAME" start;; *) return 1;; esac; } -ok(){ case "$(init_system)" in systemd) systemctl is-active --quiet "$SERVICE_NAME";; openrc) rc-service "$SERVICE_NAME" status >/dev/null 2>&1;; *) return 1;; esac; } - -backup="$DATA_DIR/backups/$(date +%Y%m%d%H%M%S)"; mkdir -p "$backup" -cp -a "$BASE" "$backup/base" -[ -d "$CONFIG_DIR" ] && cp -a "$CONFIG_DIR" "$backup/config" -[ -f "$DATA_DIR/3m-ui.db" ] && cp -p "$DATA_DIR/3m-ui.db" "$backup/3m-ui.db" -[ -x "$MIHOMO_BIN" ] && cp -p "$MIHOMO_BIN" "$backup/mihomo" || true -prune_backups - -panel_tmp="$(mktemp)"; manager_tmp="$(mktemp)"; installer_tmp="$(mktemp)"; updater_tmp="$(mktemp)"; uninstall_tmp="$(mktemp)"; entry_tmp="$(mktemp)"; mihomo_tmp=""; staging="$(mktemp -d)" -cleanup(){ rm -f "$panel_tmp" "$manager_tmp" "$installer_tmp" "$updater_tmp" "$uninstall_tmp" "$entry_tmp" "$mihomo_tmp" "${mihomo_tmp:+$mihomo_tmp.gz}"; rm -rf "$staging"; } -trap cleanup EXIT INT TERM - -# Official releases are pure-Go static only (no -static suffix). -current_mode="static" -if [ -s "$MODE_FILE" ]; then - current_mode="$(cat "$MODE_FILE")" -fi -cpu="$(arch)" -asset="3m-ui-linux-${cpu}" - -if [ -n "$REQUESTED_VERSION" ]; then - tag="$REQUESTED_VERSION" -else - tag="$(latest_tag https://github.com/$REPO)" -fi -[ -n "$tag" ] || { echo "Error: unable to determine latest 3m-ui release." >&2; exit 1; } -echo "[1/5] Downloading 3m-ui $tag ($asset)..." -if ! download "https://github.com/$REPO/releases/download/${tag}/${asset}" "$panel_tmp" 2>/dev/null; then - # Legacy tags used a -static suffix. - asset="3m-ui-linux-${cpu}-static" - echo "Retrying legacy asset: $asset" - download "https://github.com/$REPO/releases/download/${tag}/${asset}" "$panel_tmp" -fi -verify_release_sha256 "$tag" "$asset" "$panel_tmp" -# Provenance: verify the release's SHA256SUMS was signed by this repo's -# release.yml workflow (cosign keyless). OPT-IN via THREE_M_UI_VERIFY_COSIGN=1. -verify_release_cosign "$tag" "$REPO" -chmod 0755 "$panel_tmp" -# Smoke-test the binary is a Linux ELF — do NOT execute it as root (C-5). -if command_exists file; then - file "$panel_tmp" | grep -q "ELF" || { echo "Error: downloaded 3m-ui is not a valid ELF binary." >&2; exit 1; } -fi - -download_release_script(){ - file="$1"; out="$2" - # H-1: do NOT fall back to raw.githubusercontent.com — releases MUST contain all scripts. - if ! download "https://github.com/$REPO/releases/download/${tag}/${file}" "$out" 2>/dev/null; then - echo "Error: release $tag does not contain $file. Refusing to fall back to raw.githubusercontent.com for supply-chain safety; use a complete release." >&2 - exit 1 - fi - verify_release_sha256 "$tag" "$file" "$out" - chmod 0755 "$out" -} - -for spec in "3m-ui.sh:$manager_tmp" "install.sh:$installer_tmp" "update.sh:$updater_tmp" "uninstall.sh:$uninstall_tmp" "3m-ui:$entry_tmp"; do - file="${spec%%:*}"; out="${spec#*:}" - download_release_script "$file" "$out" -done - -if [ "$UPDATE_MIHOMO" -eq 1 ] && [ -x "$MIHOMO_BIN" ]; then - # Always use MetaCubeX /releases/latest (stable); do not reuse 3m-ui pre-channel tag logic. - mtag="$(curl -fsSLI -o /dev/null -w '%{url_effective}' https://github.com/MetaCubeX/mihomo/releases/latest 2>/dev/null | sed 's#.*/##; s/[[:space:][:cntrl:]]*$//')" || true - case "$mtag" in http*|HTML|*latest*|"") mtag="$(curl -fsSL https://api.github.com/repos/MetaCubeX/mihomo/releases/latest 2>/dev/null | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)" || true;; esac - if [ -n "$mtag" ]; then - mihomo_tmp="$(mktemp)" - case "$(uname -m)" in - x86_64|amd64) mihomo_candidates="mihomo-linux-amd64-compatible mihomo-linux-amd64";; - aarch64|arm64) mihomo_candidates="mihomo-linux-arm64";; - armv7l|armv7*) mihomo_candidates="mihomo-linux-armv7";; - armv6l|armv6*) mihomo_candidates="mihomo-linux-armv6";; - i386|i486|i586|i686|x86) mihomo_candidates="mihomo-linux-386";; - riscv64) mihomo_candidates="mihomo-linux-riscv64";; - loongarch64|loong64) mihomo_candidates="mihomo-linux-loong64-abi2.0 mihomo-linux-loong64";; - *) mihomo_candidates="";; - esac - mihomo_gzname="" - mihomo_asset="" - for cand in $mihomo_candidates; do - try="${cand}-${mtag}.gz" - if download_quiet "https://github.com/MetaCubeX/mihomo/releases/download/${mtag}/${try}" "$mihomo_tmp.gz"; then - mihomo_asset="$cand" - mihomo_gzname="$try" - break - fi - done - if [ -n "$mihomo_gzname" ]; then - # Verify the .gz checksum if possible (Mihomo does not publish SHA256SUMS; - # we query GitHub Release API for the per-asset digest). Best-effort by - # default because anonymous GitHub API is rate-limited (60/hour/IP). - # Set THREE_M_UI_VERIFY_MIHOMO=1 to enforce verification. - if verify_mihomo_sha256 "$mtag" "$mihomo_gzname" "$mihomo_tmp.gz"; then - : # verified - elif [ "${THREE_M_UI_VERIFY_MIHOMO:-0}" = "1" ]; then - echo "Error: Mihomo checksum verification failed (THREE_M_UI_VERIFY_MIHOMO=1 set). Set THREE_M_UI_INSECURE=1 to bypass (NOT recommended)." >&2 - exit 1 - else - echo "Warning: could not verify Mihomo checksum; continuing (HTTPS + ELF smoke-test). Set THREE_M_UI_VERIFY_MIHOMO=1 to enforce." >&2 - fi - if gzip -dc "$mihomo_tmp.gz" > "$mihomo_tmp"; then - chmod 0755 "$mihomo_tmp" - # Smoke-test: confirm ELF without executing the downloaded binary as root (C-5). - if command_exists file; then - file "$mihomo_tmp" | grep -q "ELF" || { echo "Warning: downloaded Mihomo is not a valid ELF binary; skipping update." >&2; mihomo_tmp=""; } - fi - else - mihomo_tmp="" - fi - else - echo "Note: could not download Mihomo ${mtag} for this arch (asset missing or network); keeping existing binary." - mihomo_tmp="" - fi - fi -fi - -echo "[2/5] Validating management scripts..." -sh -n "$manager_tmp" "$installer_tmp" "$updater_tmp" "$uninstall_tmp" "$entry_tmp" - -echo "[3/5] Installing release-consistent files..." -stop -mkdir -p "$staging" -install -m 0755 "$panel_tmp" "$staging/3m-ui-bin" -install -m 0755 "$manager_tmp" "$staging/3m-ui.sh" -install -m 0755 "$installer_tmp" "$staging/install.sh" -install -m 0755 "$updater_tmp" "$staging/update.sh" -install -m 0755 "$uninstall_tmp" "$staging/uninstall.sh" -install -m 0755 "$entry_tmp" "$staging/3m-ui" -printf '%s\n' "$tag" > "$staging/VERSION" -printf '%s\n' "$current_mode" > "$staging/BUILD_MODE" - -rm -rf "$BASE" -mkdir -p "$BASE" -cp -a "$staging/." "$BASE/" -install -m 0755 "$BASE/3m-ui" "$ENTRY" -if [ -n "$mihomo_tmp" ] && [ -s "$mihomo_tmp" ]; then install -m 0755 "$mihomo_tmp" "$MIHOMO_BIN"; fi -chmod 0600 "$VERSION_FILE" "$MODE_FILE" - -# Give the new binary time to migrate DB / bind ports before health checks. -# A too-eager is-active check was rolling back healthy installs ("panel dead after update"). -fix_systemd_unit -if ! start; then - echo "[ERROR] systemctl/rc-service failed to start 3m-ui; restoring previous installation." >&2 - stop - rm -rf "$BASE" - cp -a "$backup/base" "$BASE" - [ -f "$backup/3m-ui.db" ] && cp -p "$backup/3m-ui.db" "$DATA_DIR/3m-ui.db" || true - [ -f "$backup/mihomo" ] && install -m 0755 "$backup/mihomo" "$MIHOMO_BIN" || true - start || true - exit 1 -fi - -echo "[4/5] Verifying service health..." -ready=0 -i=0 -while [ "$i" -lt 30 ]; do - i=$((i + 1)) - if ok; then - ready=1 - break - fi - sleep 1 -done -if [ "$ready" -ne 1 ]; then - echo "[ERROR] Service not active after ~30s; restoring backup. Check: journalctl -u 3m-ui -n 80 --no-pager" >&2 - stop - rm -rf "$BASE" - cp -a "$backup/base" "$BASE" - [ -f "$backup/3m-ui.db" ] && cp -p "$backup/3m-ui.db" "$DATA_DIR/3m-ui.db" || true - [ -f "$backup/mihomo" ] && install -m 0755 "$backup/mihomo" "$MIHOMO_BIN" || true - start || true - exit 1 -fi - -# HTTP probe: process active is not enough — API must answer (avoids "service up but panel dead"). -panel_port=$(awk '/^[[:space:]]*port:/ {print $2; exit}' "$CONFIG_DIR/config.yaml" 2>/dev/null || echo "8080") -panel_port=${panel_port:-8080} -http_ok=0 -j=0 -while [ "$j" -lt 20 ]; do - j=$((j + 1)) - code="$(curl -sS -o /dev/null -w '%{http_code}' --connect-timeout 2 --max-time 3 "http://127.0.0.1:${panel_port}/api/v1/health" 2>/dev/null || true)" - case "$code" in - 200|204|401|403) http_ok=1; break ;; - esac - sleep 1 -done -if [ "$http_ok" -ne 1 ]; then - echo "[ERROR] Service active but panel HTTP health failed on 127.0.0.1:${panel_port}; restoring backup." >&2 - echo " Check: journalctl -u 3m-ui -n 80 --no-pager" >&2 - stop - rm -rf "$BASE" - cp -a "$backup/base" "$BASE" - [ -f "$backup/3m-ui.db" ] && cp -p "$backup/3m-ui.db" "$DATA_DIR/3m-ui.db" || true - [ -f "$backup/mihomo" ] && install -m 0755 "$backup/mihomo" "$MIHOMO_BIN" || true - start || true - exit 1 -fi - -echo "[5/5] Update completed successfully." -echo "Version: $tag" -echo "Build mode: $current_mode" -echo "Backup: $backup (keeping last $BACKUP_KEEP)" -echo "Command: 3m-ui" -# Show panel port from config so user knows where to access after update. -if [ -f "$CONFIG_DIR/config.yaml" ]; then - panel_port=$(awk '/^ port:/ {print $2; exit}' "$CONFIG_DIR/config.yaml" 2>/dev/null || echo "8080") - echo "Panel: http://SERVER_IP:${panel_port}/" - echo " (to change port: 3m-ui config port )" -fi +# Installation and updates share the same verified, transactional path. +BASE="${THREE_M_UI_ROOT:-}/usr/local/lib/3m-ui" +[ -x "$BASE/install.sh" ] || { echo "Installer missing: $BASE/install.sh" >&2; exit 1; } +exec "$BASE/install.sh" --update "$@"