feat(distribution): add complete installation and recovery workflows
Ship stable-only Linux bundles and multiarch Docker images with a pinned Mihomo core. Share secure first-run initialization across native and container installs. Validate downloads before downtime, snapshot persistent state before updates, and restore the previous installation after a failed upgrade. Add backup, restore, healthcheck, and randomized administrator reset commands. Add release verification, lifecycle regression tests, and installation documentation. feat(distribution): add complete installation and recovery workflows Ship stable-only Linux bundles and multiarch Docker images with a pinned Mihomo core. Share secure first-run initialization across native and container installs. Validate downloads before downtime, snapshot persistent state before updates, and restore the previous installation after a failed upgrade. Add backup, restore, healthcheck, and randomized administrator reset commands. Add release verification, lifecycle regression tests, and installation documentation. feat(distribution): add complete installation and recovery workflows Ship stable-only Linux bundles and multiarch Docker images with a pinned Mihomo core. Share secure first-run initialization across native and container installs. Validate downloads before downtime, snapshot persistent state before updates, and restore the previous installation after a failed upgrade. Add backup, restore, healthcheck, and randomized administrator reset commands. Add release verification, lifecycle regression tests, and installation documentation. feat(distribution): add complete installation and recovery workflows Ship stable-only Linux bundles and multiarch Docker images with a pinned Mihomo core. Share secure first-run initialization across native and container installs. Validate downloads before downtime, snapshot persistent state before updates, and restore the previous installation after a failed upgrade. Add backup, restore, healthcheck, and randomized administrator reset commands. Add release verification, lifecycle regression tests, and installation documentation. feat(distribution): add complete installation and recovery workflows Ship stable-only Linux bundles and multiarch Docker images with a pinned Mihomo core. Share secure first-run initialization across native and container installs. Validate downloads before downtime, snapshot persistent state before updates, and restore the previous installation after a failed upgrade. Add backup, restore, healthcheck, and randomized administrator reset commands. Add release verification, lifecycle regression tests, and installation documentation. feat(distribution): add complete installation and recovery workflows Ship stable-only Linux bundles and multiarch Docker images with a pinned Mihomo core. Share secure first-run initialization across native and container installs. Validate downloads before downtime, snapshot persistent state before updates, and restore the previous installation after a failed upgrade. Add backup, restore, healthcheck, and randomized administrator reset commands. Add release verification, lifecycle regression tests, and installation documentation. feat(distribution): add complete installation and recovery workflows Ship stable-only Linux bundles and multiarch Docker images with a pinned Mihomo core. Share secure first-run initialization across native and container installs. Validate downloads before downtime, snapshot persistent state before updates, and restore the previous installation after a failed upgrade. Add backup, restore, healthcheck, and randomized administrator reset commands. Add release verification, lifecycle regression tests, and installation documentation. feat(distribution): add complete installation and recovery workflows Ship stable-only Linux bundles and multiarch Docker images with a pinned Mihomo core. Share secure first-run initialization across native and container installs. Validate downloads before downtime, snapshot persistent state before updates, and restore the previous installation after a failed upgrade. Add backup, restore, healthcheck, and randomized administrator reset commands. Add release verification, lifecycle regression tests, and installation documentation. feat(distribution): add complete installation and recovery workflows Ship stable-only Linux bundles and multiarch Docker images with a pinned Mihomo core. Share secure first-run initialization across native and container installs. Validate downloads before downtime, snapshot persistent state before updates, and restore the previous installation after a failed upgrade. Add backup, restore, healthcheck, and randomized administrator reset commands. Add release verification, lifecycle regression tests, and installation documentation. feat(distribution): add complete installation and recovery workflows Ship stable-only Linux bundles and multiarch Docker images with a pinned Mihomo core. Share secure first-run initialization across native and container installs. Validate downloads before downtime, snapshot persistent state before updates, and restore the previous installation after a failed upgrade. Add backup, restore, healthcheck, and randomized administrator reset commands. Add release verification, lifecycle regression tests, and installation documentation. feat(distribution): add complete installation and recovery workflows Ship stable-only Linux bundles and multiarch Docker images with a pinned Mihomo core. Share secure first-run initialization across native and container installs. Validate downloads before downtime, snapshot persistent state before updates, and restore the previous installation after a failed upgrade. Add backup, restore, healthcheck, and randomized administrator reset commands. Add release verification, lifecycle regression tests, and installation documentation. feat(distribution): add complete installation and recovery workflows Ship stable-only Linux bundles and multiarch Docker images with a pinned Mihomo core. Share secure first-run initialization across native and container installs. Validate downloads before downtime, snapshot persistent state before updates, and restore the previous installation after a failed upgrade. Add backup, restore, healthcheck, and randomized administrator reset commands. Add release verification, lifecycle regression tests, and installation documentation.
This commit is contained in:
1 parent
0e5af8a096
commit
2e6256fb6a
46 files changed
+3360
-1574
No files matched your search
@@ -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-*
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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<<EOF'
|
||||
echo "$IMAGE:$IMAGE_TAG"
|
||||
if [ "$IS_LATEST" = true ]; then echo "$IMAGE:latest"; fi
|
||||
echo EOF
|
||||
} >> "$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[@]}"
|
||||
@@ -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/<tag>' \\
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \\
|
||||
SHA256SUMS
|
||||
```
|
||||
(replace `<tag>` 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/<owner>/<repository>: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
|
||||
+97
-197
@@ -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/<tag>' \\
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \\
|
||||
SHA256SUMS
|
||||
```
|
||||
(replace `<tag>` 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/<owner>/<repository>:<tag>.
|
||||
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' }}
|
||||
@@ -4,3 +4,6 @@ node_modules/
|
||||
*.log
|
||||
|
||||
frontend-variants/
|
||||
|
||||
# Python helper bytecode
|
||||
__pycache__/
|
||||
+47
-30
@@ -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:<digest> guarantees byte-identical rebuilds. Compute
|
||||
# digests with `docker pull <image> && docker inspect --format='{{.RepoDigests}}' <image>`
|
||||
# and replace each `FROM` line below with `<image>@sha256:<digest>`.
|
||||
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"]
|
||||
@@ -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
|
||||
|
||||
|
||||
+5
-2
@@ -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/
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package main
|
||||
import "fmt"
|
||||
|
||||
var (
|
||||
version = "v1.0.0"
|
||||
version = "dev"
|
||||
gitCommit = "unknown"
|
||||
buildTime = "unknown"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
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
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -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
|
||||
@@ -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:
|
||||
+13
-30
@@ -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/<owner>/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:
|
||||
|
||||
@@ -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 可见性适合公众拉取。
|
||||
|
||||
发布验证应涵盖两种架构、首次初始化、真实核心运行、登录改密、创建节点、实际代理连接、更新保留数据和快照恢复。仅编译成功不能证明一台全新服务器可以安装。
|
||||
+30
-21
@@ -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 显示本帮助'
|
||||
|
||||
+18
-25
@@ -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
|
||||
|
||||
Executable
+31
@@ -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
|
||||
Executable
+27
@@ -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"
|
||||
+387
-507
@@ -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 <<USAGE
|
||||
Usage: install.sh [VERSION] [--yes] [--no-mihomo]
|
||||
3m-ui update [VERSION] [--yes] [--no-mihomo]
|
||||
3m-ui backup
|
||||
3m-ui restore /absolute/path/to/snapshot.tar.gz [--yes]
|
||||
|
||||
cleanup_tmp(){
|
||||
# shellcheck disable=SC2086
|
||||
[ -n "$TMP_FILES" ] && rm -f $TMP_FILES 2>/dev/null || true
|
||||
}
|
||||
trap cleanup_tmp EXIT INT TERM
|
||||
|
||||
track_tmp(){
|
||||
TMP_FILES="$TMP_FILES $1"
|
||||
}
|
||||
|
||||
usage(){ cat <<EOF
|
||||
3m-ui installer
|
||||
|
||||
Usage: $0 [VERSION] [options]
|
||||
|
||||
Options:
|
||||
-y, --yes Non-interactive installation
|
||||
--no-mihomo Do not install Mihomo when it is missing
|
||||
--static Prefer static build (default; official releases are static)
|
||||
--dynamic Legacy no-op (releases are static pure-Go only)
|
||||
-h, --help Show this help
|
||||
The default channel is stable. Complete panel + Mihomo bundles support amd64
|
||||
and arm64. Other release architectures require --no-mihomo and a custom core.
|
||||
|
||||
Environment:
|
||||
THREE_M_UI_CHANNEL=prerelease Prefer pre-release (default on test scripts)
|
||||
THREE_M_UI_STATIC=1 Prefer static build (default)
|
||||
THREE_M_UI_INSECURE=1 Bypass SHA256SUMS verification (NOT recommended; supply-chain risk)
|
||||
THREE_M_UI_REPO=owner/repo Release source (saved for future upgrades)
|
||||
THREE_M_UI_CHANNEL=stable|pre Update channel (saved for future upgrades)
|
||||
THREE_M_UI_VERIFY_COSIGN=1 Require release provenance verification
|
||||
PANEL_PORT=8080 Initial panel port (existing config is kept)
|
||||
THREE_M_UI_MIHOMO_BINARY=PATH Explicit external core for a new installation
|
||||
|
||||
Supported CPU: x86_64, aarch64, armv7, armv6, i386/i686, riscv64, loongarch64, ppc64le, s390x
|
||||
Supported OS: Alpine, Debian/Ubuntu, RHEL/Fedora/CentOS/Rocky/Alma, Arch, openSUSE, Gentoo, Void, …
|
||||
EOF
|
||||
A version selects that release for this operation; 'pre' selects the pre channel.
|
||||
Updates stop the service only after downloads pass checksum validation, keep a
|
||||
complete local snapshot, and restore it if initialization or health checks fail.
|
||||
USAGE
|
||||
}
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-y|--yes) YES=1;;
|
||||
--no-mihomo) INSTALL_MIHOMO=0;;
|
||||
--static) STATIC=1;;
|
||||
--dynamic) STATIC=1;; # official artifacts are always static pure-Go
|
||||
--static|--dynamic) :;; # Compatibility: official panel artifacts are static.
|
||||
--update) OPERATION=update;;
|
||||
--backup) OPERATION=backup;;
|
||||
--restore) OPERATION=restore;;
|
||||
-h|--help) usage; exit 0;;
|
||||
v[0-9]*|manual-[0-9]*|test-[0-9a-zA-Z._-]*|pre) [ -z "$REQUESTED_VERSION" ] || err "Only one version may be specified."; REQUESTED_VERSION="$arg";;
|
||||
*) err "Unknown option: $arg";;
|
||||
v[0-9]*|manual-[0-9]*|test-[0-9a-zA-Z._-]*|pre)
|
||||
[ -z "$REQUESTED_VERSION" ] || err 'Only one version may be specified.'
|
||||
REQUESTED_VERSION="$arg";;
|
||||
/*) [ "$OPERATION" = restore ] || err "Unknown argument: $arg"; SNAPSHOT="$arg";;
|
||||
*) err "Unknown argument: $arg";;
|
||||
esac
|
||||
done
|
||||
|
||||
os_id(){
|
||||
if [ -r /etc/os-release ]; then . /etc/os-release; printf '%s' "${ID:-unknown}"; else printf '%s' unknown; fi
|
||||
init_system(){
|
||||
if [ -d "$ROOT/run/systemd/system" ] && command_exists systemctl; then echo systemd
|
||||
elif command_exists rc-service; then echo openrc
|
||||
else echo unsupported; fi
|
||||
}
|
||||
service_action(){
|
||||
case "$INIT" in
|
||||
systemd) systemctl "$1" "$SERVICE_NAME";;
|
||||
openrc) rc-service "$SERVICE_NAME" "$1";;
|
||||
*) return 1;;
|
||||
esac
|
||||
}
|
||||
service_active(){
|
||||
case "$INIT" in
|
||||
systemd) systemctl is-active --quiet "$SERVICE_NAME";;
|
||||
openrc) rc-service "$SERVICE_NAME" status >/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" <<EOF
|
||||
server:
|
||||
port: ${panel_port}
|
||||
listen: "${panel_listen}"
|
||||
public_url: "${public_url}"
|
||||
mode: release
|
||||
database:
|
||||
path: "$DATA_DIR/3m-ui.db"
|
||||
jwt:
|
||||
secret: "$(random_hex 32)"
|
||||
security:
|
||||
credential_key: "$(random_hex 32)"
|
||||
mihomo:
|
||||
binary: "$MIHOMO_BIN"
|
||||
config: "$DATA_DIR/mihomo/config.yaml"
|
||||
EOF
|
||||
chmod 0600 "$CONFIG_DIR/config.yaml"
|
||||
if [ "$TRANSACTION" = 0 ] && [ "$STOPPED" = 1 ] && [ "$WAS_RUNNING" = 1 ]; then
|
||||
service_action start || say 'Could not restart the previous service; inspect 3m-ui logs.' >&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-<arch>.
|
||||
# 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 <<EOF
|
||||
mkdir -p "$(dirname "$UNIT")"
|
||||
if [ "$INIT" = systemd ]; then
|
||||
cat > "$UNIT" <<UNITFILE
|
||||
[Unit]
|
||||
Description=3m-ui server panel and Mihomo Core
|
||||
Description=3m-ui panel and Mihomo Core
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=$APP_BIN
|
||||
Environment=THREE_M_UI_CONFIG=$CONFIG_DIR/config.yaml
|
||||
Environment=THREE_M_UI_CONFIG=$CONFIG_FILE
|
||||
WorkingDirectory=$DATA_DIR
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
KillMode=control-group
|
||||
UMask=0077
|
||||
# Sandbox hardening (H-2). The service still runs as root because Mihomo TUN
|
||||
# mode requires CAP_NET_ADMIN, which is hard to delegate to a non-root user.
|
||||
# Sandbox without SystemCallFilter: pure-Go + modernc SQLite needs syscalls
|
||||
# outside @system-service; seccomp was killing the process with SIGSYS (31).
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
@@ -513,83 +340,136 @@ LockPersonality=true
|
||||
SystemCallArchitectures=native
|
||||
NoNewPrivileges=true
|
||||
LimitNOFILE=65535
|
||||
# Allow binding privileged ports (80/443) for optional panel ACME/SSL,
|
||||
# and CAP_NET_ADMIN for Mihomo TUN mode.
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_NET_ADMIN
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_NET_ADMIN
|
||||
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload; systemctl enable "$SERVICE_NAME" >/dev/null; systemctl restart "$SERVICE_NAME"; }
|
||||
|
||||
write_openrc(){ cat > /etc/init.d/$SERVICE_NAME <<EOF
|
||||
UNITFILE
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$SERVICE_NAME" >/dev/null
|
||||
else
|
||||
cat > "$UNIT" <<UNITFILE
|
||||
#!/sbin/openrc-run
|
||||
description="3m-ui server panel and Mihomo Core"
|
||||
description="3m-ui panel and Mihomo Core"
|
||||
command="$APP_BIN"
|
||||
command_background="yes"
|
||||
pidfile="/run/$SERVICE_NAME.pid"
|
||||
directory="$DATA_DIR"
|
||||
export THREE_M_UI_CONFIG="$CONFIG_DIR/config.yaml"
|
||||
export THREE_M_UI_CONFIG="$CONFIG_FILE"
|
||||
output_log="$LOG_DIR/$SERVICE_NAME.log"
|
||||
error_log="$LOG_DIR/$SERVICE_NAME.log"
|
||||
respawn_delay=5
|
||||
supervisor=supervise-daemon
|
||||
|
||||
depend() { need net; after firewall; }
|
||||
EOF
|
||||
chmod 0755 /etc/init.d/$SERVICE_NAME; rc-update add "$SERVICE_NAME" default >/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 "$@"
|
||||
@@ -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="")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
+23
-17
@@ -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."
|
||||
+4
-497
@@ -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 <new-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 "$@"
|
||||
Reference in new issue
Block a user