diff --git a/.github/workflows/FNOS.yml b/.github/workflows/FNOS.yml index 5b1b275e..10a3d3a4 100644 --- a/.github/workflows/FNOS.yml +++ b/.github/workflows/FNOS.yml @@ -1,5 +1,8 @@ name: FNOS +permissions: + contents: read + on: workflow_dispatch: push: @@ -13,16 +16,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout frontend code - uses: actions/checkout@v3 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: - repository: dongshull/Docker-Copilot-React + repository: autunn/Docker-Copilot-React path: './frontend' - ref: 'master' + ref: 'afc2105211eb1adaa417ce8c125f2a91789c9bf1' - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: '22' + node-version: '22.23.2' cache: 'npm' cache-dependency-path: | frontend/package-lock.json @@ -32,10 +35,11 @@ jobs: run: | cd frontend npm ci + npm run test --if-present npm run build - name: Upload Frontend Artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: my-artifact-front-dist path: frontend/dist @@ -48,10 +52,10 @@ jobs: arch: [ amd64, arm64 ] steps: - name: Checkout code back end - uses: actions/checkout@v3 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Download Frontend Artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: pattern: my-artifact-front-* merge-multiple: true @@ -64,19 +68,15 @@ jobs: run: | echo "version=$(cat fn_version)" >> $GITHUB_ENV - - name: Cache Go Modules - uses: actions/cache@v3 - with: - path: ${{ github.workspace }}/msaber-back/pkg/mod - key: go-mod-${{ hashFiles('**/go.sum') }} - restore-keys: | - go-mod- - - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: - go-version: '1.23.4' - cache: false + go-version: '1.25.13' + cache-dependency-path: go.sum + + - name: Run backend tests + if: matrix.arch == 'amd64' + run: go test ./... - name: Build for ${{ matrix.arch }} run: | @@ -84,36 +84,45 @@ jobs: - name: Compress binary run: | - tar -czvf dockerCopilot-${{ matrix.arch }}.tar.gz dist/linux/${{ matrix.arch }}/dockerCopilot-new + tar -C dist/linux/${{ matrix.arch }} -czvf dockerCopilot-${{ matrix.arch }}.tar.gz dockerCopilot-new + sha256sum dockerCopilot-${{ matrix.arch }}.tar.gz > dockerCopilot-${{ matrix.arch }}.tar.gz.sha256 - name: List current directory run: ls -la - - name: Check if release already exists - id: check_release - run: | - exists=$(gh release view ${{ env.version }} 2>&1 | grep "release not found" || true) - if [[ -z "$exists" ]]; then - echo "Release exists" - echo "RELEASE_EXISTS=true" >> $GITHUB_ENV - else - echo "Release does not exist" - echo "RELEASE_EXISTS=false" >> $GITHUB_ENV - fi - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload release assets for ${{ matrix.arch }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-assets-${{ matrix.arch }} + path: | + dockerCopilot-${{ matrix.arch }}.tar.gz + dockerCopilot-${{ matrix.arch }}.tar.gz.sha256 + if-no-files-found: error + + release: + needs: build-backend + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout backend code + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - name: Create GitHub Release if not exists - if: env.RELEASE_EXISTS == 'false' - run: | - gh release create ${{ env.version }} \ - --title "${{ env.version }}" \ - --notes "Release of version ${{ env.version }}" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Get version from file + run: echo "version=$(cat fn_version)" >> "$GITHUB_ENV" - - name: Upload Asset for ${{ matrix.arch }} - run: | - gh release upload ${{ env.version }} dockerCopilot-${{ matrix.arch }}.tar.gz + - name: Download release assets + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: release-assets-* + merge-multiple: true + path: release-assets + + - name: Create release and upload all assets env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if ! gh release view "$version" >/dev/null 2>&1; then + gh release create "$version" --title "$version" --notes "Release of version $version" + fi + gh release upload "$version" release-assets/* --clobber diff --git a/.github/workflows/UGREEN.yml b/.github/workflows/UGREEN.yml index 1cc21b95..a0fcb325 100644 --- a/.github/workflows/UGREEN.yml +++ b/.github/workflows/UGREEN.yml @@ -1,5 +1,8 @@ name: UGREEN +permissions: + contents: read + on: workflow_dispatch: push: @@ -13,16 +16,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout frontend code - uses: actions/checkout@v3 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: - repository: dongshull/Docker-Copilot-React + repository: autunn/Docker-Copilot-React path: './frontend' - ref: 'master' + ref: 'afc2105211eb1adaa417ce8c125f2a91789c9bf1' - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: '22' + node-version: '22.23.2' cache: 'npm' cache-dependency-path: | frontend/package-lock.json @@ -32,10 +35,11 @@ jobs: run: | cd frontend npm ci + npm run test --if-present npm run build - name: Upload Frontend Artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: my-artifact-front-dist path: frontend/dist @@ -48,10 +52,10 @@ jobs: arch: [ amd64, arm64 ] steps: - name: Checkout code back end - uses: actions/checkout@v3 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Download Frontend Artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: pattern: my-artifact-front-* merge-multiple: true @@ -64,19 +68,15 @@ jobs: run: | echo "version=$(cat ugreen_version)" >> $GITHUB_ENV - - name: Cache Go Modules - uses: actions/cache@v3 - with: - path: ${{ github.workspace }}/msaber-back/pkg/mod - key: go-mod-${{ hashFiles('**/go.sum') }} - restore-keys: | - go-mod- - - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: - go-version: '1.23.4' - cache: false + go-version: '1.25.13' + cache-dependency-path: go.sum + + - name: Run backend tests + if: matrix.arch == 'amd64' + run: go test ./... - name: Build for ${{ matrix.arch }} run: | @@ -84,36 +84,45 @@ jobs: - name: Compress binary run: | - tar -czvf dockerCopilot-${{ matrix.arch }}.tar.gz dist/linux/${{ matrix.arch }}/dockerCopilot-new + tar -C dist/linux/${{ matrix.arch }} -czvf dockerCopilot-${{ matrix.arch }}.tar.gz dockerCopilot-new + sha256sum dockerCopilot-${{ matrix.arch }}.tar.gz > dockerCopilot-${{ matrix.arch }}.tar.gz.sha256 - name: List current directory run: ls -la - - name: Check if release already exists - id: check_release - run: | - exists=$(gh release view ${{ env.version }} 2>&1 | grep "release not found" || true) - if [[ -z "$exists" ]]; then - echo "Release exists" - echo "RELEASE_EXISTS=true" >> $GITHUB_ENV - else - echo "Release does not exist" - echo "RELEASE_EXISTS=false" >> $GITHUB_ENV - fi - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload release assets for ${{ matrix.arch }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-assets-${{ matrix.arch }} + path: | + dockerCopilot-${{ matrix.arch }}.tar.gz + dockerCopilot-${{ matrix.arch }}.tar.gz.sha256 + if-no-files-found: error + + release: + needs: build-backend + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout backend code + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - name: Create GitHub Release if not exists - if: env.RELEASE_EXISTS == 'false' - run: | - gh release create ${{ env.version }} \ - --title "${{ env.version }}" \ - --notes "Release of version ${{ env.version }}" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Get version from file + run: echo "version=$(cat ugreen_version)" >> "$GITHUB_ENV" - - name: Upload Asset for ${{ matrix.arch }} - run: | - gh release upload ${{ env.version }} dockerCopilot-${{ matrix.arch }}.tar.gz + - name: Download release assets + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: release-assets-* + merge-multiple: true + path: release-assets + + - name: Create release and upload all assets env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if ! gh release view "$version" >/dev/null 2>&1; then + gh release create "$version" --title "$version" --notes "Release of version $version" + fi + gh release upload "$version" release-assets/* --clobber diff --git a/.github/workflows/docker-build-push-UGREEN-stable.yml b/.github/workflows/docker-build-push-UGREEN-stable.yml index 18dd13da..34b3d744 100644 --- a/.github/workflows/docker-build-push-UGREEN-stable.yml +++ b/.github/workflows/docker-build-push-UGREEN-stable.yml @@ -1,5 +1,8 @@ name: docker-build-push-UGREEN-stable +permissions: + contents: read + on: workflow_dispatch: push: @@ -13,16 +16,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout frontend code - uses: actions/checkout@v3 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: - repository: dongshull/Docker-Copilot-React + repository: autunn/Docker-Copilot-React path: './frontend' - ref: 'master' + ref: 'afc2105211eb1adaa417ce8c125f2a91789c9bf1' - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: '22' + node-version: '22.23.2' cache: 'npm' cache-dependency-path: | frontend/package-lock.json @@ -32,10 +35,11 @@ jobs: run: | cd frontend npm ci + npm run test --if-present npm run build - name: Upload Frontend Artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: my-artifact-front-dist path: frontend/dist @@ -48,10 +52,10 @@ jobs: arch: [ amd64, arm64 ] steps: - name: Checkout code back end - uses: actions/checkout@v3 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Download Frontend Artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: pattern: my-artifact-front-* merge-multiple: true @@ -64,19 +68,15 @@ jobs: run: | echo "version=$(cat ugreen_version)" >> $GITHUB_ENV - - name: Cache Go Modules - uses: actions/cache@v3 - with: - path: ${{ github.workspace }}/msaber-back/pkg/mod - key: go-mod-${{ hashFiles('**/go.sum') }} - restore-keys: | - go-mod- - - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: - go-version: '1.23.4' - cache: false + go-version: '1.25.13' + cache-dependency-path: go.sum + + - name: Run backend tests + if: matrix.arch == 'amd64' + run: go test ./... - name: Build for ${{ matrix.arch }} run: | @@ -89,7 +89,7 @@ jobs: cp -r etc dc-back-${{ matrix.arch }}/etc - name: Upload Backend Artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: my-artifact-backend-${{ matrix.arch }} path: dc-back-${{ matrix.arch }} @@ -99,10 +99,10 @@ jobs: needs: [ build-frontend, build-backend ] steps: - name: Checkout code Now Code - uses: actions/checkout@v3 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Download Backend Artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: pattern: my-artifact-backend-* merge-multiple: true @@ -111,20 +111,20 @@ jobs: - run: ls -R dc-back - name: Docker Setup Buildx - uses: docker/setup-buildx-action@v3.0.0 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Docker Login - uses: docker/login-action@v3.0.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push Docker images - uses: docker/build-push-action@v5.0.0 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: ./docker/Dockerfile platforms: linux/amd64,linux/arm64 tags: | ${{ secrets.DOCKERHUB_USERNAME }}/dockercopilot:UGREEN - push: true \ No newline at end of file + push: true diff --git a/.github/workflows/docker-build-push-UGREEN.yml b/.github/workflows/docker-build-push-UGREEN.yml index 6d27da34..eb539395 100644 --- a/.github/workflows/docker-build-push-UGREEN.yml +++ b/.github/workflows/docker-build-push-UGREEN.yml @@ -1,5 +1,8 @@ name: docker-build-push-UGREEN-dev +permissions: + contents: read + on: workflow_dispatch: push: @@ -11,16 +14,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout frontend code - uses: actions/checkout@v3 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: - repository: dongshull/Docker-Copilot-React + repository: autunn/Docker-Copilot-React path: './frontend' - ref: 'master' + ref: 'afc2105211eb1adaa417ce8c125f2a91789c9bf1' - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: '22' + node-version: '22.23.2' cache: 'npm' cache-dependency-path: | frontend/package-lock.json @@ -30,10 +33,11 @@ jobs: run: | cd frontend npm ci + npm run test --if-present npm run build - name: Upload Frontend Artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: my-artifact-front-dist path: frontend/dist @@ -46,10 +50,10 @@ jobs: arch: [ amd64, arm64 ] steps: - name: Checkout code back end - uses: actions/checkout@v3 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Download Frontend Artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: pattern: my-artifact-front-* merge-multiple: true @@ -62,19 +66,15 @@ jobs: run: | echo "version=$(cat ugreen_version)" >> $GITHUB_ENV - - name: Cache Go Modules - uses: actions/cache@v3 - with: - path: ${{ github.workspace }}/msaber-back/pkg/mod - key: go-mod-${{ hashFiles('**/go.sum') }} - restore-keys: | - go-mod- - - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: - go-version: '1.23.4' - cache: false + go-version: '1.25.13' + cache-dependency-path: go.sum + + - name: Run backend tests + if: matrix.arch == 'amd64' + run: go test ./... - name: Build for ${{ matrix.arch }} run: | @@ -87,7 +87,7 @@ jobs: cp -r etc dc-back-${{ matrix.arch }}/etc - name: Upload Backend Artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: my-artifact-backend-${{ matrix.arch }} path: dc-back-${{ matrix.arch }} @@ -97,10 +97,10 @@ jobs: needs: [ build-frontend, build-backend ] steps: - name: Checkout code Now Code - uses: actions/checkout@v3 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Download Backend Artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: pattern: my-artifact-backend-* merge-multiple: true @@ -109,20 +109,20 @@ jobs: - run: ls -R dc-back - name: Docker Setup Buildx - uses: docker/setup-buildx-action@v3.0.0 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Docker Login - uses: docker/login-action@v3.0.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push Docker images - uses: docker/build-push-action@v5.0.0 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: ./docker/Dockerfile platforms: linux/amd64,linux/arm64 tags: | ${{ secrets.DOCKERHUB_USERNAME }}/dockercopilot:UGREEN-DEV - push: true \ No newline at end of file + push: true diff --git a/.github/workflows/docker-build-push-latest-stable.yml b/.github/workflows/docker-build-push-latest-stable.yml index 27753a4b..7961a0de 100644 --- a/.github/workflows/docker-build-push-latest-stable.yml +++ b/.github/workflows/docker-build-push-latest-stable.yml @@ -1,5 +1,8 @@ name: docker-build-push-latest-stable +permissions: + contents: read + on: workflow_dispatch: push: @@ -13,16 +16,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout frontend code - uses: actions/checkout@v3 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: - repository: dongshull/Docker-Copilot-React + repository: autunn/Docker-Copilot-React path: './frontend' - ref: 'master' + ref: 'afc2105211eb1adaa417ce8c125f2a91789c9bf1' - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: '22' + node-version: '22.23.2' cache: 'npm' cache-dependency-path: | frontend/package-lock.json @@ -32,10 +35,11 @@ jobs: run: | cd frontend npm ci + npm run test --if-present npm run build - name: Upload Frontend Artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: my-artifact-front-dist path: frontend/dist @@ -48,10 +52,10 @@ jobs: arch: [ amd64, arm64 ] steps: - name: Checkout code back end - uses: actions/checkout@v3 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Download Frontend Artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: pattern: my-artifact-front-* merge-multiple: true @@ -64,19 +68,15 @@ jobs: run: | echo "version=$(cat version)" >> $GITHUB_ENV - - name: Cache Go Modules - uses: actions/cache@v3 - with: - path: ${{ github.workspace }}/msaber-back/pkg/mod - key: go-mod-${{ hashFiles('**/go.sum') }} - restore-keys: | - go-mod- - - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: - go-version: '1.23.4' - cache: false + go-version: '1.25.13' + cache-dependency-path: go.sum + + - name: Run backend tests + if: matrix.arch == 'amd64' + run: go test ./... - name: Build for ${{ matrix.arch }} run: | @@ -89,7 +89,7 @@ jobs: cp -r etc dc-back-${{ matrix.arch }}/etc - name: Upload Backend Artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: my-artifact-backend-${{ matrix.arch }} path: dc-back-${{ matrix.arch }} @@ -99,10 +99,10 @@ jobs: needs: [ build-frontend, build-backend ] steps: - name: Checkout code Now Code - uses: actions/checkout@v3 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Download Backend Artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: pattern: my-artifact-backend-* merge-multiple: true @@ -111,20 +111,20 @@ jobs: - run: ls -R dc-back - name: Docker Setup Buildx - uses: docker/setup-buildx-action@v3.0.0 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Docker Login - uses: docker/login-action@v3.0.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push Docker images - uses: docker/build-push-action@v5.0.0 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: ./docker/Dockerfile platforms: linux/amd64,linux/arm64 tags: | ${{ secrets.DOCKERHUB_USERNAME }}/dockercopilot:latest - push: true \ No newline at end of file + push: true diff --git a/.github/workflows/docker-build-push-latest.yml b/.github/workflows/docker-build-push-latest.yml index b9f1f705..27bc9a74 100644 --- a/.github/workflows/docker-build-push-latest.yml +++ b/.github/workflows/docker-build-push-latest.yml @@ -1,5 +1,8 @@ name: docker-build-push-latest-dev +permissions: + contents: read + on: workflow_dispatch: push: @@ -11,16 +14,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout frontend code - uses: actions/checkout@v3 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: - repository: dongshull/Docker-Copilot-React + repository: autunn/Docker-Copilot-React path: './frontend' - ref: 'master' + ref: 'afc2105211eb1adaa417ce8c125f2a91789c9bf1' - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: '22' + node-version: '22.23.2' cache: 'npm' cache-dependency-path: | frontend/package-lock.json @@ -30,10 +33,11 @@ jobs: run: | cd frontend npm ci + npm run test --if-present npm run build - name: Upload Frontend Artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: my-artifact-front-dist path: frontend/dist @@ -46,10 +50,10 @@ jobs: arch: [ amd64, arm64 ] steps: - name: Checkout code back end - uses: actions/checkout@v3 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Download Frontend Artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: pattern: my-artifact-front-* merge-multiple: true @@ -62,19 +66,15 @@ jobs: run: | echo "version=$(cat version)" >> $GITHUB_ENV - - name: Cache Go Modules - uses: actions/cache@v3 - with: - path: ${{ github.workspace }}/msaber-back/pkg/mod - key: go-mod-${{ hashFiles('**/go.sum') }} - restore-keys: | - go-mod- - - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: - go-version: '1.23.4' - cache: false + go-version: '1.25.13' + cache-dependency-path: go.sum + + - name: Run backend tests + if: matrix.arch == 'amd64' + run: go test ./... - name: Build for ${{ matrix.arch }} run: | @@ -87,7 +87,7 @@ jobs: cp -r etc dc-back-${{ matrix.arch }}/etc - name: Upload Backend Artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: my-artifact-backend-${{ matrix.arch }} path: dc-back-${{ matrix.arch }} @@ -97,10 +97,10 @@ jobs: needs: [ build-frontend, build-backend ] steps: - name: Checkout code Now Code - uses: actions/checkout@v3 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Download Backend Artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: pattern: my-artifact-backend-* merge-multiple: true @@ -109,16 +109,16 @@ jobs: - run: ls -R dc-back - name: Docker Setup Buildx - uses: docker/setup-buildx-action@v3.0.0 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Docker Login - uses: docker/login-action@v3.0.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push Docker images - uses: docker/build-push-action@v5.0.0 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: ./docker/Dockerfile diff --git a/.github/workflows/ghcr-latest.yml b/.github/workflows/ghcr-latest.yml new file mode 100644 index 00000000..8670cefb --- /dev/null +++ b/.github/workflows/ghcr-latest.yml @@ -0,0 +1,178 @@ +name: ghcr-latest + +permissions: + contents: read + +on: + workflow_dispatch: + inputs: + frontend_ref: + description: Immutable Docker-Copilot-React commit SHA + required: false + type: string + push: + branches: + - latest + paths: + - version + +concurrency: + group: ghcr-latest-${{ github.ref }} + cancel-in-progress: true + +env: + FRONTEND_REPOSITORY: autunn/Docker-Copilot-React + FRONTEND_REF: ${{ inputs.frontend_ref || 'afc2105211eb1adaa417ce8c125f2a91789c9bf1' }} + IMAGE_NAME: ghcr.io/autunn/dockercopilot + +jobs: + build-frontend: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout frontend + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ env.FRONTEND_REPOSITORY }} + ref: ${{ env.FRONTEND_REF }} + path: frontend + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.23.2 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Test and build frontend + working-directory: frontend + run: | + npm ci + npm test + npm run build + + - name: Upload frontend artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ghcr-frontend-dist + path: frontend/dist + if-no-files-found: error + retention-days: 1 + + build-backend: + needs: build-frontend + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: true + matrix: + arch: + - amd64 + - arm64 + steps: + - name: Checkout backend + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Download frontend artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ghcr-frontend-dist + path: front + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: 1.25.13 + cache-dependency-path: go.sum + + - name: Validate version + shell: bash + run: | + version="$(tr -d '\r\n' < version)" + if [[ ! "$version" =~ ^[vV]?[0-9A-Za-z][0-9A-Za-z._+-]*$ ]]; then + echo "Invalid version: $version" >&2 + exit 1 + fi + printf 'VERSION=%s\n' "$version" >> "$GITHUB_ENV" + + - name: Run backend tests + if: matrix.arch == 'amd64' + run: go test ./... + + - name: Build backend + shell: bash + run: | + build_date="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + CGO_ENABLED=0 GOOS=linux GOARCH=${{ matrix.arch }} \ + go build -a --trimpath \ + -ldflags="-w -s -X github.com/onlyLTY/dockerCopilot/internal/config.Version=$VERSION -X github.com/onlyLTY/dockerCopilot/internal/config.BuildDate=$build_date" \ + -o dist/linux/${{ matrix.arch }}/dockerCopilot . + + - name: Prepare backend artifact + shell: bash + run: | + mkdir -p package + cp -R dist package/dist + cp -R etc package/etc + + - name: Upload backend artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ghcr-backend-${{ matrix.arch }} + path: package + if-no-files-found: error + retention-days: 1 + + publish: + needs: + - build-frontend + - build-backend + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: write + steps: + - name: Checkout backend + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Download backend artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: ghcr-backend-* + path: dc-back + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + + - name: Log in to GHCR + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and publish image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: docker/Dockerfile + platforms: linux/amd64,linux/arm64 + pull: true + push: true + tags: | + ${{ env.IMAGE_NAME }}:latest + ${{ env.IMAGE_NAME }}:sha-${{ github.sha }} + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + cache-from: type=gha,scope=ghcr-latest + cache-to: type=gha,mode=max,scope=ghcr-latest + provenance: mode=max + sbom: true diff --git a/.github/workflows/latest.yml b/.github/workflows/latest.yml index 58d72eed..1d78191d 100644 --- a/.github/workflows/latest.yml +++ b/.github/workflows/latest.yml @@ -1,5 +1,8 @@ name: latest +permissions: + contents: read + on: workflow_dispatch: push: @@ -13,16 +16,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout frontend code - uses: actions/checkout@v3 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: - repository: dongshull/Docker-Copilot-React + repository: autunn/Docker-Copilot-React path: './frontend' - ref: 'master' + ref: 'afc2105211eb1adaa417ce8c125f2a91789c9bf1' - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: '22' + node-version: '22.23.2' cache: 'npm' cache-dependency-path: | frontend/package-lock.json @@ -32,10 +35,11 @@ jobs: run: | cd frontend npm ci + npm run test --if-present npm run build - name: Upload Frontend Artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: my-artifact-front-dist path: frontend/dist @@ -48,10 +52,10 @@ jobs: arch: [ amd64, arm64 ] steps: - name: Checkout code back end - uses: actions/checkout@v3 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Download Frontend Artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: pattern: my-artifact-front-* merge-multiple: true @@ -64,19 +68,15 @@ jobs: run: | echo "version=$(cat version)" >> $GITHUB_ENV - - name: Cache Go Modules - uses: actions/cache@v3 - with: - path: ${{ github.workspace }}/msaber-back/pkg/mod - key: go-mod-${{ hashFiles('**/go.sum') }} - restore-keys: | - go-mod- - - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: - go-version: '1.23.4' - cache: false + go-version: '1.25.13' + cache-dependency-path: go.sum + + - name: Run backend tests + if: matrix.arch == 'amd64' + run: go test ./... - name: Build for ${{ matrix.arch }} run: | @@ -84,36 +84,45 @@ jobs: - name: Compress binary run: | - tar -czvf dockerCopilot-${{ matrix.arch }}.tar.gz dist/linux/${{ matrix.arch }}/dockerCopilot-new + tar -C dist/linux/${{ matrix.arch }} -czvf dockerCopilot-${{ matrix.arch }}.tar.gz dockerCopilot-new + sha256sum dockerCopilot-${{ matrix.arch }}.tar.gz > dockerCopilot-${{ matrix.arch }}.tar.gz.sha256 - name: List current directory run: ls -la - - name: Check if release already exists - id: check_release - run: | - exists=$(gh release view ${{ env.version }} 2>&1 | grep "release not found" || true) - if [[ -z "$exists" ]]; then - echo "Release exists" - echo "RELEASE_EXISTS=true" >> $GITHUB_ENV - else - echo "Release does not exist" - echo "RELEASE_EXISTS=false" >> $GITHUB_ENV - fi - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload release assets for ${{ matrix.arch }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-assets-${{ matrix.arch }} + path: | + dockerCopilot-${{ matrix.arch }}.tar.gz + dockerCopilot-${{ matrix.arch }}.tar.gz.sha256 + if-no-files-found: error + + release: + needs: build-backend + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout backend code + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - name: Create GitHub Release if not exists - if: env.RELEASE_EXISTS == 'false' - run: | - gh release create ${{ env.version }} \ - --title "${{ env.version }}" \ - --notes "Release of version ${{ env.version }}" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Get version from file + run: echo "version=$(cat version)" >> "$GITHUB_ENV" - - name: Upload Asset for ${{ matrix.arch }} - run: | - gh release upload ${{ env.version }} dockerCopilot-${{ matrix.arch }}.tar.gz + - name: Download release assets + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: release-assets-* + merge-multiple: true + path: release-assets + + - name: Create release and upload all assets env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if ! gh release view "$version" >/dev/null 2>&1; then + gh release create "$version" --title "$version" --notes "Release of version $version" + fi + gh release upload "$version" release-assets/* --clobber diff --git a/README.md b/README.md index bcd86172..11b5395b 100644 --- a/README.md +++ b/README.md @@ -26,21 +26,47 @@ services: dockercopilot: container_name: dockercopilot restart: always - privileged: true network_mode: bridge ports: - - 12712:12712 + - 127.0.0.1:12712:12712 volumes: - /var/run/docker.sock:/var/run/docker.sock - ./data:/data environment: - TZ=Asia/Shanghai - DOCKER_HOST=unix:///var/run/docker.sock - - secretKey=密码,不少于八位且非纯数字 - image: 0nlylty/dockercopilot:latest + - secretKey=请设置你自己的登录密码 + image: ghcr.io/autunn/dockercopilot:latest ``` +`privileged` 不是必需项;请只挂载 Docker Socket,并限制管理端口的网络访问。注意:挂载 Docker Socket 实际上授予了主机级管理权限,因此不要把管理端口直接暴露到公网。生产环境建议通过反向代理启用 HTTPS,或同时设置 `TLS_CERT_FILE` 与 `TLS_KEY_FILE`。`secretKey` 完全由用户决定;短密码和纯数字不会阻止启动,也不会在普通运行日志中反复提示。为降低口令猜测风险,仍建议使用不少于 32 个字符的随机密码。备份默认使用 `secretKey` 加密,修改加密密钥后旧备份将无法解密。 + +更新公开 GHCR 镜像: + +```bash +docker compose pull dockercopilot +docker compose up -d dockercopilot +``` + +容器镜像默认禁用进程内二进制自更新,Docker Copilot 也不会在管理页面中原地更新自身。这样可以避免运行中的容器替换自己的可执行文件;请始终通过 Compose 拉取并重建。 + +可选安全配置: + +- `CORS_ALLOWED_ORIGINS`:逗号分隔的可信前端 Origin;默认不开放跨域。 +- `BACKUP_ENCRYPTION_KEY`:用户可自行设置的独立备份密钥;短密钥不会阻止使用,生产环境仍建议使用不少于 32 个字符的随机值并妥善保存。 +- `TLS_CERT_FILE`、`TLS_KEY_FILE`:同时设置时由服务直接启用 TLS。 +- `DOCKER_AUTH_CONFIG` 或 `DOCKER_CONFIG`:检查私有 Registry 镜像更新时使用的 Docker 凭据。 +- `TRUSTED_PROXY_CIDRS`:逗号分隔的可信反向代理 CIDR。只有请求直连地址属于这些网段时,登录限速才会读取 `X-Forwarded-For`;不要填写不受你控制的网络。 +- `UPDATE_CHANNEL`:更新频道,默认 `latest`;`githubProxy` 如设置,必须生成 HTTPS 地址。 +- `UPDATE_REPOSITORY`:版本检查仓库,格式为 `owner/repository`,镜像默认使用 `autunn/dockerCopilot`。 +- `DISABLE_BINARY_SELF_UPDATE`:镜像内默认为 `true`,不建议在容器部署中关闭。 +- `COMPOSE_BACKUP_INCLUDE_SECRETS`:默认为 `false`,Compose 备份会将密码、令牌等敏感环境变量保留为空引用而不写入值。设为 `true` 后敏感值会以明文进入 YAML,请自行保护和清理备份。 + +图标匹配优先使用用户上传、本地内置图标,再匹配固定版本的 Homarr Labs Dashboard Icons。浏览器只会为已命中的公共图标请求 `raw.githubusercontent.com`;未知或离线镜像使用本地生成的稳定回退图标,不会把完整私有仓库路径发送给第三方。第三方许可见前端仓库的 `THIRD_PARTY_NOTICES.md`。 + +备份说明:JSON 备份使用备份密钥加密;Compose YAML 主要用于重建容器配置,不包含卷内业务数据。恢复和更新会复用 Docker 客户端的 Registry 凭据。若使用私有镜像,请确保容器能读取正确的 `DOCKER_AUTH_CONFIG` 或挂载只读 Docker 配置目录。 + ## 开发环境 -go版本:1.21+ +Go 版本:1.25.13+ diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..fc0362a0 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +# Security notes + +## Deployment boundary + +Docker Copilot controls the Docker daemon through its socket. Access to the +application therefore has host-equivalent administrative impact. Bind the +management port to a trusted network, use HTTPS, protect `secretKey`, and do +not expose the service directly to the public Internet. + +Only configure `TRUSTED_PROXY_CIDRS` for reverse proxies you administer. The +application ignores forwarded client-address headers from every other peer. + +## Dependency advisories + +`govulncheck` currently reports `GO-2026-4887` and `GO-2026-4883` through the +Moby module. No fixed Moby release is available at the time of this review. +Both advisories affect Docker daemon AuthZ/plugin server behavior; Docker +Copilot imports and uses the Docker client API and does not run those server +components. The dependency should still be upgraded as soon as a compatible +fixed release becomes available. + +## Backups and credentials + +JSON backups are encrypted. Compose exports omit sensitive environment values +by default, but are plaintext when `COMPOSE_BACKUP_INCLUDE_SECRETS=true`. +Registry credentials are read from Docker auth configuration and are never +included in API responses. diff --git a/docker/Dockerfile b/docker/Dockerfile index 83592031..75b8a830 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,6 +1,6 @@ # 使用 Alpine 作为基础镜像 -FROM alpine -LABEL authors="onlyLTY" +FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce +LABEL org.opencontainers.image.authors="autunn" WORKDIR /app ARG TARGETPLATFORM @@ -14,11 +14,15 @@ RUN apk add --no-cache tzdata \ && chmod +x dockerCopilot # 设置环境变量 -ENV secretKey="" \ - DOCKER_HOST="unix:///var/run/docker.sock" \ +ENV DOCKER_HOST="unix:///var/run/docker.sock" \ BACKUP_DIR="/data/backups" \ + DISABLE_BINARY_SELF_UPDATE="true" \ + UPDATE_REPOSITORY="autunn/dockerCopilot" \ + UPDATE_CHANNEL="latest" \ TZ="Asia/Shanghai" VOLUME ["/data"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD ["./dockerCopilot", "-healthcheck"] # 设置启动命令 -CMD ["./start.sh"] \ No newline at end of file +CMD ["./start.sh"] diff --git a/dockercopilot.api b/dockercopilot.api index 0716fb6d..4faf68c0 100644 --- a/dockercopilot.api +++ b/dockercopilot.api @@ -80,6 +80,7 @@ type ContainerUpdateReq { IdReq ImageNameAndTag string `form:"imageNameAndTag"` ContainerName string `form:"containerName"` + DelOldContainer bool `form:"delOldContainer,default=true"` } type VersionMsgResp { @@ -140,14 +141,17 @@ service dockerCopilot { post /container/:id/update (ContainerUpdateReq) returns (Resp) @handler backup - get /container/backup returns (Resp) + post /container/backup returns (Resp) @handler backup2compose - get /container/backup2compose returns (Resp) + post /container/backup2compose returns (Resp) @handler listBackups get /container/listBackups returns (Resp) + @handler downloadBackup + get /container/backups/download (DelContainerBackupReq) returns (Resp) + @handler restore post /container/backups/restore (ContainerRestoreReq) returns (Resp) diff --git a/dockercopilot.go b/dockercopilot.go index f9143b9e..6f8706a7 100644 --- a/dockercopilot.go +++ b/dockercopilot.go @@ -1,14 +1,20 @@ package main import ( + "context" + "crypto/tls" "embed" "flag" "fmt" "go/types" "io/fs" "log" + "net" "net/http" "os" + "strconv" + "strings" + "time" "github.com/onlyLTY/dockerCopilot/internal/config" "github.com/onlyLTY/dockerCopilot/internal/handler" @@ -26,7 +32,10 @@ import ( //go:embed front/* var embeddedFront embed.FS -var configFile = flag.String("f", "etc/dockerCopilot.yaml", "the config file") +var ( + configFile = flag.String("f", "etc/dockerCopilot.yaml", "the config file") + healthCheckOnly = flag.Bool("healthcheck", false, "check whether the HTTP server port is accepting connections") +) type UnauthorizedResponse struct { Code int `json:"code"` @@ -35,6 +44,26 @@ type UnauthorizedResponse struct { } func main() { + flag.Parse() + var c config.Config + err := conf.Load(*configFile, &c, conf.UseEnv()) + if *healthCheckOnly { + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "health check cannot load configuration: %v\n", err) + os.Exit(1) + } + address, err := healthCheckAddress(c) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if err := checkTCPHealth(address, 2*time.Second); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + logDir := "./logs" ErrSetupLog := SetupLog(logDir) if ErrSetupLog != nil { @@ -43,15 +72,15 @@ func main() { } logx.SetLevel(logx.InfoLevel) - flag.Parse() - var c config.Config - err := conf.Load(*configFile, &c, conf.UseEnv()) if err != nil { logx.Errorf("无法加载配置文件出错: %v", err) - logx.Errorf("请确认secretKey设置正确,要求非纯数字且大于八位") + logx.Errorf("请确认 secretKey 环境变量已设置且配置文件格式正确") os.Exit(1) } - server := rest.MustNewServer(c.RestConf, rest.WithCors("*"), rest.WithUnauthorizedCallback( + for _, warning := range runtimeSecurityWarnings(c) { + logx.Debugf("安全提示(不阻止启动): %s", warning) + } + serverOptions := []rest.RunOption{rest.WithUnauthorizedCallback( func(w http.ResponseWriter, r *http.Request, err error) { response := UnauthorizedResponse{ Code: http.StatusUnauthorized, // 401 @@ -59,61 +88,78 @@ func main() { Data: map[string]interface{}{}, } httpx.WriteJson(w, http.StatusUnauthorized, response) + })} + if rawOrigins := strings.TrimSpace(os.Getenv("CORS_ALLOWED_ORIGINS")); rawOrigins != "" { + origins := strings.FieldsFunc(rawOrigins, func(r rune) bool { return r == ',' }) + serverOptions = append(serverOptions, rest.WithCors(origins...)) + } + certFile := strings.TrimSpace(os.Getenv("TLS_CERT_FILE")) + keyFile := strings.TrimSpace(os.Getenv("TLS_KEY_FILE")) + if certFile != "" || keyFile != "" { + if certFile == "" || keyFile == "" { + logx.Error("TLS_CERT_FILE 和 TLS_KEY_FILE 必须同时配置") + os.Exit(1) + } + certificate, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + logx.Errorf("加载 TLS 证书失败: %v", err) + os.Exit(1) + } + serverOptions = append(serverOptions, rest.WithTLSConfig(&tls.Config{ + MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{certificate}, })) + } + server := rest.MustNewServer(c.RestConf, serverOptions...) + server.Use(securityHeaders) defer server.Stop() ctx := svc.NewServiceContext(c) + if ctx.DockerClient != nil { + defer ctx.DockerClient.Close() + } + imageCheckContext, cancelImageChecks := context.WithCancel(context.Background()) + defer cancelImageChecks() // Ensure data directory and config exist (Auto-init) dataDir := "/data/config/image" - if err := os.MkdirAll(dataDir, 0755); err != nil { + if err := os.MkdirAll(dataDir, 0700); err != nil { logx.Errorf("Failed to create data directory: %v", err) + os.Exit(1) } - imageLogosPath := "/data/config/imageLogos.js" - if _, err := os.Stat(imageLogosPath); os.IsNotExist(err) { - defaultConfig := []byte(`// 自定义镜像logo配置 -export const customImageLogos = { -}; -`) - if err := os.WriteFile(imageLogosPath, defaultConfig, 0644); err != nil { - logx.Errorf("Failed to create default imageLogos.js: %v", err) - } - } - - list, err := utiles.GetImagesList(ctx) - if err != nil { - logx.Errorf("panic获取镜像列表出错: %v", err) - panic(err) + if list, err := utiles.GetImagesList(ctx); err != nil { + logx.Errorf("首次获取镜像列表失败,将在定时任务中重试: %v", err) + } else { + go ctx.HubImageInfo.CheckUpdate(imageCheckContext, ctx.DockerClient, list) } - go ctx.HubImageInfo.CheckUpdate(list) corndanmu := cron.New(cron.WithParser(cron.NewParser( - cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow, - ))) + cron.Minute|cron.Hour|cron.Dom|cron.Month|cron.Dow, + )), cron.WithChain(cron.Recover(cron.DefaultLogger))) _, err = corndanmu.AddFunc("30 * * * *", func() { + ctx.CleanupProgress(time.Hour) list, err := utiles.GetImagesList(ctx) if err != nil { - logx.Errorf("panic获取镜像列表出错: %v", err) - panic(err) + logx.Errorf("定时获取镜像列表失败: %v", err) + return } - ctx.HubImageInfo.CheckUpdate(list) + ctx.HubImageInfo.CheckUpdate(imageCheckContext, ctx.DockerClient, list) }) if err != nil { - logx.Errorf("panic添加定时任务出错: %v", err) - panic(err) + logx.Errorf("添加定时任务失败: %v", err) } corndanmu.Start() defer corndanmu.Stop() httpx.SetErrorHandler(func(err error) (int, any) { switch e := err.(type) { case *errors.CodeMsg: - return http.StatusOK, xhttp.BaseResponse[types.Nil]{ + return http.StatusBadRequest, xhttp.BaseResponse[types.Nil]{ Code: e.Code, Msg: e.Msg, } default: - return http.StatusOK, xhttp.BaseResponse[types.Nil]{ - Code: 50000, - Msg: err.Error(), + logx.Errorf("未处理的 HTTP 错误: %v", err) + return http.StatusInternalServerError, xhttp.BaseResponse[types.Nil]{ + Code: http.StatusInternalServerError, + Msg: "内部服务器错误", } } }) @@ -123,6 +169,69 @@ export const customImageLogos = { logx.Info("程序版本" + config.Version) server.Start() } + +func healthCheckAddress(c config.Config) (string, error) { + if c.Port <= 0 || c.Port > 65535 { + return "", fmt.Errorf("health check port is invalid: %d", c.Port) + } + host := "127.0.0.1" + if strings.Contains(c.Host, ":") { + host = "::1" + } + return net.JoinHostPort(host, strconv.Itoa(c.Port)), nil +} + +func checkTCPHealth(address string, timeout time.Duration) error { + connection, err := net.DialTimeout("tcp", address, timeout) + if err != nil { + return fmt.Errorf("health check failed for %s: %w", address, err) + } + return connection.Close() +} + +func runtimeSecurityWarnings(c config.Config) []string { + warnings := make([]string, 0, 3) + secret := c.Auth.AccessSecret + if len(secret) < 32 { + warnings = append(warnings, "secretKey 少于 32 个字符,建议使用更强的随机密码") + } + allDigits := true + for _, character := range secret { + if character < '0' || character > '9' { + allDigits = false + break + } + } + if secret != "" && allDigits { + warnings = append(warnings, "secretKey 为纯数字,建议使用包含字母和符号的密码") + } + backupSecret := os.Getenv("BACKUP_ENCRYPTION_KEY") + if backupSecret != "" && len(backupSecret) < 32 { + warnings = append(warnings, "BACKUP_ENCRYPTION_KEY 少于 32 个字符,建议使用更强的随机密钥") + } + return warnings +} + +func securityHeaders(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + header := w.Header() + header.Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self' https://api.github.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; worker-src 'self'") + header.Set("X-Content-Type-Options", "nosniff") + header.Set("X-Frame-Options", "DENY") + header.Set("Referrer-Policy", "no-referrer") + header.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + header.Set("Cross-Origin-Opener-Policy", "same-origin") + header.Set("Cross-Origin-Resource-Policy", "same-origin") + if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api" { + header.Set("Cache-Control", "no-store") + header.Set("Pragma", "no-cache") + } + if r.TLS != nil { + header.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + } + next(w, r) + } +} func RegisterHandlers(engine *rest.Server) { frontFS, err := fs.Sub(embeddedFront, "front") if err != nil { @@ -183,17 +292,18 @@ func RegisterHandlers(engine *rest.Server) { // 检查并创建日志目录 func ensureLogDirectory(logDir string) error { - if _, err := os.Stat(logDir); os.IsNotExist(err) { - return os.MkdirAll(logDir, 0755) // 创建目录并设置权限 + if err := os.MkdirAll(logDir, 0o700); err != nil { + return err } - return nil + // #nosec G302 -- directories require execute permission; 0700 is owner-only. + return os.Chmod(logDir, 0o700) } // SetupLog 初始化日志设置 func SetupLog(logDir string) error { // 检查日志目录是否存在 if err := ensureLogDirectory(logDir); err != nil { - return fmt.Errorf("failed to create log directory: %v", err) + return fmt.Errorf("failed to create log directory: %w", err) } logConf := logx.LogConf{ diff --git a/dockercopilot_test.go b/dockercopilot_test.go new file mode 100644 index 00000000..532c63b0 --- /dev/null +++ b/dockercopilot_test.go @@ -0,0 +1,143 @@ +package main + +import ( + "fmt" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/onlyLTY/dockerCopilot/internal/config" + "github.com/zeromicro/go-zero/rest" +) + +func TestHealthCheckAddressUsesConfiguredPort(t *testing.T) { + cfg := config.Config{} + cfg.Host = "0.0.0.0" + cfg.Port = 18080 + address, err := healthCheckAddress(cfg) + if err != nil { + t.Fatal(err) + } + if address != "127.0.0.1:18080" { + t.Fatalf("unexpected health address %q", address) + } +} + +func TestRuntimeSecurityWarningsAreAdvisory(t *testing.T) { + t.Setenv("BACKUP_ENCRYPTION_KEY", "") + var cfg config.Config + cfg.Auth.AccessSecret = "123456" + warnings := runtimeSecurityWarnings(cfg) + if len(warnings) != 2 { + t.Fatalf("expected short and numeric warnings, got %v", warnings) + } + cfg.Auth.AccessSecret = "0123456789abcdef-strong-random-key" + if warnings := runtimeSecurityWarnings(cfg); len(warnings) != 0 { + t.Fatalf("strong secret produced warnings: %v", warnings) + } +} + +func TestRuntimeSecurityWarningsAllowUserChosenBackupKey(t *testing.T) { + t.Setenv("BACKUP_ENCRYPTION_KEY", "too-short") + var cfg config.Config + cfg.Auth.AccessSecret = "0123456789abcdef-strong-random-key" + warnings := runtimeSecurityWarnings(cfg) + if len(warnings) != 1 { + t.Fatalf("expected one advisory warning, got %v", warnings) + } +} + +func TestSecurityHeaders(t *testing.T) { + handler := securityHeaders(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) + request := httptest.NewRequest(http.MethodGet, "https://example.test/manager", nil) + recorder := httptest.NewRecorder() + handler(recorder, request) + + if recorder.Code != http.StatusNoContent { + t.Fatalf("unexpected response code %d", recorder.Code) + } + for _, name := range []string{"Content-Security-Policy", "Strict-Transport-Security", "X-Content-Type-Options", "Cross-Origin-Opener-Policy"} { + if recorder.Header().Get(name) == "" { + t.Fatalf("security header %s is missing", name) + } + } +} + +func TestSecurityHeadersDisableAPICaching(t *testing.T) { + handler := securityHeaders(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) + request := httptest.NewRequest(http.MethodGet, "http://example.test/api/containers", nil) + recorder := httptest.NewRecorder() + handler(recorder, request) + if recorder.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("API response cache policy is %q", recorder.Header().Get("Cache-Control")) + } +} + +func TestFrontendRoutesStartWithoutDuplicates(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to allocate test port: %v", err) + } + port := listener.Addr().(*net.TCPAddr).Port + if err := listener.Close(); err != nil { + t.Fatalf("failed to release test port: %v", err) + } + + var cfg config.Config + cfg.Host = "127.0.0.1" + cfg.Port = port + server := rest.MustNewServer(cfg.RestConf) + RegisterHandlers(server) + startResult := make(chan any, 1) + go func() { + defer func() { startResult <- recover() }() + server.Start() + }() + + client := &http.Client{Timeout: 250 * time.Millisecond} + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + select { + case recovered := <-startResult: + if recovered != nil { + t.Fatalf("server panicked while registering frontend routes: %v", recovered) + } + t.Fatal("server stopped before becoming ready") + default: + } + + response, requestErr := client.Get(fmt.Sprintf("http://127.0.0.1:%d/manager/", port)) + if requestErr == nil { + response.Body.Close() + if response.StatusCode != http.StatusOK { + server.Stop() + t.Fatalf("GET /manager/ returned %d", response.StatusCode) + } + server.Stop() + return + } + time.Sleep(50 * time.Millisecond) + } + server.Stop() + t.Fatal("frontend server did not become ready") +} + +func TestTCPHealthCheck(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to create test listener: %v", err) + } + address := listener.Addr().String() + if err := checkTCPHealth(address, time.Second); err != nil { + listener.Close() + t.Fatalf("health check rejected a listening server: %v", err) + } + if err := listener.Close(); err != nil { + t.Fatalf("failed to close test listener: %v", err) + } + if err := checkTCPHealth(address, 100*time.Millisecond); err == nil { + t.Fatal("health check accepted a closed server") + } +} diff --git a/etc/dockerCopilot.yaml b/etc/dockerCopilot.yaml index fd64a275..f188ef3c 100644 --- a/etc/dockerCopilot.yaml +++ b/etc/dockerCopilot.yaml @@ -1,9 +1,9 @@ Name: dockerCopilot Host: 0.0.0.0 Port: 12712 -Timeout: 100000000000000 +Timeout: 30000 Log: Encoding: plain Auth: AccessSecret: ${secretKey} - AccessExpire: 7257600 \ No newline at end of file + AccessExpire: 43200 diff --git a/go.mod b/go.mod index 4bfabbf8..0030a78a 100644 --- a/go.mod +++ b/go.mod @@ -1,82 +1,85 @@ module github.com/onlyLTY/dockerCopilot -go 1.23.0 - -toolchain go1.24.1 +go 1.25.13 require ( github.com/compose-spec/compose-go v1.20.2 github.com/distribution/reference v0.5.0 - github.com/docker/docker v27.3.1+incompatible + github.com/docker/docker v28.5.2+incompatible github.com/docker/go-connections v0.5.0 - github.com/golang-jwt/jwt v3.2.2+incompatible + github.com/golang-jwt/jwt/v4 v4.5.2 github.com/google/uuid v1.6.0 + github.com/opencontainers/image-spec v1.0.2 github.com/robfig/cron/v3 v3.0.1 - github.com/zeromicro/go-zero v1.8.2 + github.com/zeromicro/go-zero v1.10.3 github.com/zeromicro/x v0.0.0-20240408115609-8224c482b07e - sigs.k8s.io/yaml v1.3.0 + sigs.k8s.io/yaml v1.6.0 ) require ( github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-jwt/jwt/v4 v4.5.2 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect + github.com/grafana/pyroscope-go v1.3.0 // indirect + github.com/grafana/pyroscope-go/godeltaprof v0.1.10 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-shellwords v1.0.12 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/term v0.5.0 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.0.2 // indirect github.com/openzipkin/zipkin-go v0.4.3 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pelletier/go-toml/v2 v2.4.3 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/prometheus/client_golang v1.22.0 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.63.0 // indirect - github.com/prometheus/procfs v0.16.0 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.47.0 // indirect - go.opentelemetry.io/otel v1.35.0 // indirect - go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect - go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 // indirect - go.opentelemetry.io/otel/exporters/zipkin v1.35.0 // indirect - go.opentelemetry.io/otel/metric v1.35.0 // indirect - go.opentelemetry.io/otel/sdk v1.35.0 // indirect - go.opentelemetry.io/otel/trace v1.35.0 // indirect - go.opentelemetry.io/proto/otlp v1.5.0 // indirect + github.com/titanous/json5 v1.0.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.45.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 // indirect + go.opentelemetry.io/otel/exporters/zipkin v1.45.0 // indirect + go.opentelemetry.io/otel/metric v1.45.0 // indirect + go.opentelemetry.io/otel/sdk v1.45.0 // indirect + go.opentelemetry.io/otel/trace v1.45.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect - golang.org/x/mod v0.17.0 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/sync v0.13.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/text v0.24.0 // indirect - golang.org/x/time v0.10.0 // indirect - golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250409194420-de1ac958c67a // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250409194420-de1ac958c67a // indirect - google.golang.org/grpc v1.71.1 // indirect - google.golang.org/protobuf v1.36.6 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.48.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect + google.golang.org/grpc v1.83.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.5.0 // indirect diff --git a/go.sum b/go.sum index 5150eca4..9cf893c0 100644 --- a/go.sum +++ b/go.sum @@ -4,12 +4,16 @@ github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migc github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/compose-spec/compose-go v1.20.2 h1:u/yfZHn4EaHGdidrZycWpxXgFffjYULlTbRfJ51ykjQ= github.com/compose-spec/compose-go v1.20.2/go.mod h1:+MdqXV4RA7wdFsahh/Kb8U0pAJqkg7mr4PM9tFKU8RM= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= @@ -19,8 +23,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0= github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI= -github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -30,14 +34,10 @@ github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= -github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -46,14 +46,16 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= +github.com/grafana/pyroscope-go v1.3.0 h1:t3Jehad8vvqN4oRAB0LdmfQ5ZSUXQw3asoft+K4GAT8= +github.com/grafana/pyroscope-go v1.3.0/go.mod h1:XA7I3usNx+UdjOZfQnl1WV8y924vsJo9KIVrKB+9jx4= +github.com/grafana/pyroscope-go/godeltaprof v0.1.10 h1:dvhndEbyavTb59vFCd6PsrAG5qi69/qZZtegh/TJKSY= +github.com/grafana/pyroscope-go/godeltaprof v0.1.10/go.mod h1:XnWRGg2XO5uxZdiz1rfeJH6w1eZ+YICCBVXNWOfH86g= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -70,6 +72,10 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= @@ -82,26 +88,28 @@ github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrB github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg= github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= -github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= -github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= -github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM= -github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/robertkrimen/otto v0.2.1 h1:FVP0PJ0AHIjC+N4pKCG9yCDz6LHNPCwi/GKID5pGGF0= +github.com/robertkrimen/otto v0.2.1/go.mod h1:UPwtJ1Xu7JrLcZjNWN8orJaM5n5YEtqL//farB5FlRY= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= @@ -110,103 +118,84 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/zeromicro/go-zero v1.8.2 h1:AbJckBoojbr1lqCN1dkvURTIHOau7yvKReEd7ZmjuCk= -github.com/zeromicro/go-zero v1.8.2/go.mod h1:G5dF+jzCEuq0t1j8qdrtVAy30QMgctGcKSfqFIGsvSg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/titanous/json5 v1.0.0 h1:hJf8Su1d9NuI/ffpxgxQfxh/UiBFZX7bMPid0rIL/7s= +github.com/titanous/json5 v1.0.0/go.mod h1:7JH1M8/LHKc6cyP5o5g3CSaRj+mBrIimTxzpvmckH8c= +github.com/zeromicro/go-zero v1.10.3 h1:fm4+jUuUF77IWtFeAyf2xVoBRcgEpF1NZJUqTvZ3dw0= +github.com/zeromicro/go-zero v1.10.3/go.mod h1:Gnac2bT/JGb9Ja79wchssVeYtJxuWWzL98DuLH11kds= github.com/zeromicro/x v0.0.0-20240408115609-8224c482b07e h1:F5waakzloTfbJg2lcO1xvrzO6ssn7jQ38lXIDBz+nbQ= github.com/zeromicro/x v0.0.0-20240408115609-8224c482b07e/go.mod h1:5TP11tc1RHPCi5C/KDL0kIB0KgJAb9FB3ChpT/qM/jA= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.47.0 h1:sv9kVfal0MK0wBMCOGr+HeJm9v803BkJxGrk2au7j08= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.47.0/go.mod h1:SK2UL73Zy1quvRPonmOmRDiWk1KBV3LyIeeIxcEApWw= -go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= -go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= -go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4= -go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 h1:xJ2qHD0C1BeYVTLLR9sX12+Qb95kfeD/byKj6Ky1pXg= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 h1:T0Ec2E+3YZf5bgTNQVet8iTDW7oIk03tXHq+wkwIDnE= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0/go.mod h1:30v2gqH+vYGJsesLWFov8u47EpYTcIQcBjKpI6pJThg= -go.opentelemetry.io/otel/exporters/zipkin v1.35.0 h1:OAx1AdClqTB3pz+B4osLuGjx8kubys8ByW7yx0lF454= -go.opentelemetry.io/otel/exporters/zipkin v1.35.0/go.mod h1:hz5wHI9hmCXzwkXFGZ05ObZw2Q2t/AeAZ18PExd2uSM= -go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= -go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= -go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= -go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= -go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= -go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= -go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= -go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= -go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= -go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 h1:fG5MCxGz8+2VtrN/WgqSpJFctVz24gpxj8CxkKmc8Ww= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0/go.mod h1:BmAYTn+3ysbRe+IU2msxmf5Rx3g6DHvex+tWI3LdhYI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 h1:QBajQ2SrwQijzHyZbQlPsuIzpl/ll8DY6wPWsajeGcI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0/go.mod h1:08ZQLjrPLQ6R4kAXvuOvODEer5Yh4CoFvll5qB2BCI8= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 h1:lsA/S1bxgdbyFGkTj+3meEdJ6ADVU7QoFstV6MXgE68= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0/go.mod h1:L7u+MirGoB1bjeLH66+xDykF4RC8C3RN7lIFpBiewUo= +go.opentelemetry.io/otel/exporters/zipkin v1.45.0 h1:KN3btaILMTxR4QDHVGAO87lq5ButzK7l+kIfLuxQ1oA= +go.opentelemetry.io/otel/exporters/zipkin v1.45.0/go.mod h1:yNcodmUclM4InyWoOwX/YW4Jri0Gj5FWAlM+NqCrtqY= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= -golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= -golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/api v0.0.0-20250409194420-de1ac958c67a h1:OQ7sHVzkx6L57dQpzUS4ckfWJ51KDH74XHTDe23xWAs= -google.golang.org/genproto/googleapis/api v0.0.0-20250409194420-de1ac958c67a/go.mod h1:2R6XrVC8Oc08GlNh8ujEpc7HkLiEZ16QeY7FxIs20ac= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250409194420-de1ac958c67a h1:GIqLhp/cYUkuGuiT+vJk8vhOP86L4+SP5j8yXgeVpvI= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250409194420-de1ac958c67a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI= -google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc= +google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= +google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= +gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI= +gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -214,7 +203,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY= gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= -k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A= -k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= -sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/handler/auth/loginhandler.go b/internal/handler/auth/loginhandler.go index e541f860..f34eded7 100644 --- a/internal/handler/auth/loginhandler.go +++ b/internal/handler/auth/loginhandler.go @@ -6,10 +6,21 @@ import ( "github.com/onlyLTY/dockerCopilot/internal/types" "github.com/zeromicro/go-zero/rest/httpx" "net/http" + "strconv" + "time" ) func LoginHandler(ctx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + clientKey := loginClientKey(r) + if allowed, retryAfter := authLimiter.allow(clientKey, time.Now()); !allowed { + w.Header().Set("Retry-After", strconv.Itoa(int(retryAfter.Round(time.Second).Seconds()))) + httpx.WriteJson(w, http.StatusTooManyRequests, types.Resp{ + Code: http.StatusTooManyRequests, Msg: "登录失败次数过多,请稍后重试", + Data: map[string]interface{}{}, + }) + return + } var req types.LoginReq if err := httpx.Parse(r, &req); err != nil { var resp types.Resp @@ -20,6 +31,7 @@ func LoginHandler(ctx *svc.ServiceContext) http.HandlerFunc { } l := auth.NewLoginLogic(r.Context(), ctx) resp, err := l.Login(&req) + authLimiter.record(clientKey, err == nil, time.Now()) if err != nil { httpx.WriteJson(w, resp.Code, resp) return diff --git a/internal/handler/auth/ratelimit.go b/internal/handler/auth/ratelimit.go new file mode 100644 index 00000000..2ba23f75 --- /dev/null +++ b/internal/handler/auth/ratelimit.go @@ -0,0 +1,153 @@ +package auth + +import ( + "net" + "net/http" + "os" + "strings" + "sync" + "time" +) + +const ( + loginFailureWindow = 5 * time.Minute + loginBlockDuration = 15 * time.Minute + maxLoginFailures = 5 + maxLoginClients = 10_000 +) + +type loginAttempt struct { + failures int + windowStart time.Time + blockedUntil time.Time + lastSeen time.Time +} + +type loginAttemptLimiter struct { + mu sync.Mutex + attempts map[string]loginAttempt +} + +var authLimiter = &loginAttemptLimiter{attempts: make(map[string]loginAttempt)} + +func (l *loginAttemptLimiter) allow(key string, now time.Time) (bool, time.Duration) { + l.mu.Lock() + defer l.mu.Unlock() + l.cleanup(now) + attempt, exists := l.attempts[key] + if !exists { + return true, 0 + } + attempt.lastSeen = now + l.attempts[key] = attempt + if now.Before(attempt.blockedUntil) { + return false, attempt.blockedUntil.Sub(now) + } + return true, 0 +} + +func (l *loginAttemptLimiter) record(key string, success bool, now time.Time) { + l.mu.Lock() + defer l.mu.Unlock() + l.cleanup(now) + if success { + delete(l.attempts, key) + return + } + attempt, exists := l.attempts[key] + if !exists && len(l.attempts) >= maxLoginClients { + l.evictOldest() + } + if attempt.windowStart.IsZero() || now.Sub(attempt.windowStart) > loginFailureWindow { + attempt.failures = 0 + attempt.windowStart = now + } + attempt.failures++ + attempt.lastSeen = now + if attempt.failures >= maxLoginFailures { + attempt.blockedUntil = now.Add(loginBlockDuration) + attempt.failures = 0 + attempt.windowStart = time.Time{} + } + l.attempts[key] = attempt +} + +func (l *loginAttemptLimiter) evictOldest() { + var oldestKey string + var oldestTime time.Time + for key, attempt := range l.attempts { + if oldestKey == "" || attempt.lastSeen.Before(oldestTime) { + oldestKey = key + oldestTime = attempt.lastSeen + } + } + if oldestKey != "" { + delete(l.attempts, oldestKey) + } +} + +func (l *loginAttemptLimiter) cleanup(now time.Time) { + for key, attempt := range l.attempts { + if now.Sub(attempt.lastSeen) > 30*time.Minute && now.After(attempt.blockedUntil) { + delete(l.attempts, key) + } + } +} + +func loginClientKey(r *http.Request) string { + directIP := remoteIP(r.RemoteAddr) + if directIP == nil { + if r.RemoteAddr == "" { + return "unknown" + } + return r.RemoteAddr + } + trustedProxies := parseTrustedProxyCIDRs(os.Getenv("TRUSTED_PROXY_CIDRS")) + if !ipInNetworks(directIP, trustedProxies) { + return directIP.String() + } + + forwarded := strings.Split(r.Header.Get("X-Forwarded-For"), ",") + for index := len(forwarded) - 1; index >= 0; index-- { + candidate := net.ParseIP(strings.TrimSpace(forwarded[index])) + if candidate == nil { + return directIP.String() + } + if !ipInNetworks(candidate, trustedProxies) { + return candidate.String() + } + } + return directIP.String() +} + +func remoteIP(remoteAddress string) net.IP { + host, _, err := net.SplitHostPort(remoteAddress) + if err == nil { + return net.ParseIP(host) + } + return net.ParseIP(strings.Trim(remoteAddress, "[]")) +} + +func parseTrustedProxyCIDRs(raw string) []*net.IPNet { + var networks []*net.IPNet + for _, value := range strings.Split(raw, ",") { + value = strings.TrimSpace(value) + if value == "" { + continue + } + _, network, err := net.ParseCIDR(value) + if err == nil { + networks = append(networks, network) + } + } + return networks +} + +func ipInNetworks(ip net.IP, networks []*net.IPNet) bool { + for _, network := range networks { + if network.Contains(ip) { + return true + } + } + return false +} diff --git a/internal/handler/auth/ratelimit_test.go b/internal/handler/auth/ratelimit_test.go new file mode 100644 index 00000000..a4db3e74 --- /dev/null +++ b/internal/handler/auth/ratelimit_test.go @@ -0,0 +1,87 @@ +package auth + +import ( + "fmt" + "net/http/httptest" + "testing" + "time" +) + +func TestLoginAttemptLimiterBlocksAndResets(t *testing.T) { + limiter := &loginAttemptLimiter{attempts: make(map[string]loginAttempt)} + now := time.Unix(1000, 0) + for index := 0; index < maxLoginFailures; index++ { + limiter.record("client", false, now.Add(time.Duration(index)*time.Second)) + } + if allowed, retry := limiter.allow("client", now.Add(10*time.Second)); allowed || retry <= 0 { + t.Fatalf("blocked client was allowed: allowed=%v retry=%s", allowed, retry) + } + limiter.record("client", true, now.Add(11*time.Second)) + if allowed, _ := limiter.allow("client", now.Add(12*time.Second)); !allowed { + t.Fatal("successful login did not reset limiter") + } +} + +func TestLoginAttemptLimiterDoesNotTrackUncheckedClients(t *testing.T) { + limiter := &loginAttemptLimiter{attempts: make(map[string]loginAttempt)} + if allowed, _ := limiter.allow("new-client", time.Now()); !allowed { + t.Fatal("new client was unexpectedly blocked") + } + if len(limiter.attempts) != 0 { + t.Fatalf("allow check allocated an entry: %d", len(limiter.attempts)) + } +} + +func TestLoginAttemptLimiterCapsTrackedClients(t *testing.T) { + limiter := &loginAttemptLimiter{attempts: make(map[string]loginAttempt)} + now := time.Unix(1000, 0) + for index := 0; index < maxLoginClients+1; index++ { + limiter.record(fmt.Sprintf("client-%d", index), false, now.Add(time.Duration(index)*time.Nanosecond)) + } + if len(limiter.attempts) != maxLoginClients { + t.Fatalf("tracked %d clients, want %d", len(limiter.attempts), maxLoginClients) + } + if _, exists := limiter.attempts["client-0"]; exists { + t.Fatal("oldest client was not evicted") + } +} + +func TestLoginClientKeyIgnoresForwardedHeaderFromUntrustedPeer(t *testing.T) { + t.Setenv("TRUSTED_PROXY_CIDRS", "10.0.0.0/8") + request := httptest.NewRequest("POST", "/api/auth", nil) + request.RemoteAddr = "192.0.2.10:12345" + request.Header.Set("X-Forwarded-For", "198.51.100.25") + if key := loginClientKey(request); key != "192.0.2.10" { + t.Fatalf("client key = %q, want direct peer", key) + } +} + +func TestLoginClientKeyUsesRightmostUntrustedForwardedAddress(t *testing.T) { + t.Setenv("TRUSTED_PROXY_CIDRS", "10.0.0.0/8") + request := httptest.NewRequest("POST", "/api/auth", nil) + request.RemoteAddr = "10.0.0.8:12345" + request.Header.Set("X-Forwarded-For", "203.0.113.99, 198.51.100.25") + if key := loginClientKey(request); key != "198.51.100.25" { + t.Fatalf("client key = %q, want rightmost untrusted hop", key) + } +} + +func TestLoginClientKeyWalksTrustedProxyChain(t *testing.T) { + t.Setenv("TRUSTED_PROXY_CIDRS", "10.0.0.0/8, 172.16.0.0/12") + request := httptest.NewRequest("POST", "/api/auth", nil) + request.RemoteAddr = "10.0.0.8:12345" + request.Header.Set("X-Forwarded-For", "198.51.100.25, 172.16.0.4") + if key := loginClientKey(request); key != "198.51.100.25" { + t.Fatalf("client key = %q, want original untrusted client", key) + } +} + +func TestLoginClientKeyRejectsMalformedForwardedChain(t *testing.T) { + t.Setenv("TRUSTED_PROXY_CIDRS", "10.0.0.0/8") + request := httptest.NewRequest("POST", "/api/auth", nil) + request.RemoteAddr = "10.0.0.8:12345" + request.Header.Set("X-Forwarded-For", "198.51.100.25, invalid") + if key := loginClientKey(request); key != "10.0.0.8" { + t.Fatalf("client key = %q, want safe direct-peer fallback", key) + } +} diff --git a/internal/handler/container/backup2composehandler.go b/internal/handler/container/backup2composehandler.go index 471a5d58..92859815 100644 --- a/internal/handler/container/backup2composehandler.go +++ b/internal/handler/container/backup2composehandler.go @@ -5,17 +5,12 @@ import ( "github.com/onlyLTY/dockerCopilot/internal/logic/container" "github.com/onlyLTY/dockerCopilot/internal/svc" - "github.com/zeromicro/go-zero/rest/httpx" ) func Backup2composeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { l := container.NewBackup2composeLogic(r.Context(), svcCtx) - resp, err := l.Backup2compose() - if err != nil { - httpx.ErrorCtx(r.Context(), w, err) - } else { - httpx.OkJsonCtx(r.Context(), w, resp) - } + resp, _ := l.Backup2compose() + writeContainerResponse(w, resp) } } diff --git a/internal/handler/container/delrestorehandler.go b/internal/handler/container/delrestorehandler.go index 50b3f2f3..b5a6c277 100644 --- a/internal/handler/container/delrestorehandler.go +++ b/internal/handler/container/delrestorehandler.go @@ -13,16 +13,12 @@ func DelRestoreHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req types.DelContainerBackupReq if err := httpx.Parse(r, &req); err != nil { - httpx.ErrorCtx(r.Context(), w, err) + writeContainerBadRequest(w) return } l := container.NewDelRestoreLogic(r.Context(), svcCtx) - resp, err := l.DelRestore(&req) - if err != nil { - httpx.WriteJson(w, resp.Code, resp) - } else { - httpx.WriteJson(w, resp.Code, resp) - } + resp, _ := l.DelRestore(&req) + writeContainerResponse(w, resp) } } diff --git a/internal/handler/container/downloadbackuphandler.go b/internal/handler/container/downloadbackuphandler.go new file mode 100644 index 00000000..866568eb --- /dev/null +++ b/internal/handler/container/downloadbackuphandler.go @@ -0,0 +1,30 @@ +package container + +import ( + "mime" + "net/http" + "strconv" + + "github.com/onlyLTY/dockerCopilot/internal/svc" + "github.com/onlyLTY/dockerCopilot/internal/types" + "github.com/onlyLTY/dockerCopilot/internal/utiles" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func DownloadBackupHandler(_ *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + content, filename, err := utiles.ReadBackupDownload(r.URL.Query().Get("filename")) + if err != nil { + httpx.WriteJson(w, http.StatusBadRequest, types.Resp{ + Code: http.StatusBadRequest, Msg: "无法下载备份文件", Data: map[string]interface{}{}, + }) + return + } + contentDisposition := mime.FormatMediaType("attachment", map[string]string{"filename": filename}) + w.Header().Set("Content-Disposition", contentDisposition) + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", strconv.Itoa(len(content))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(content) + } +} diff --git a/internal/handler/container/renamehandler.go b/internal/handler/container/renamehandler.go index d7801751..178c884c 100644 --- a/internal/handler/container/renamehandler.go +++ b/internal/handler/container/renamehandler.go @@ -12,16 +12,12 @@ func RenameHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req types.ContainerRenameReq if err := httpx.Parse(r, &req); err != nil { - httpx.ErrorCtx(r.Context(), w, err) + writeContainerBadRequest(w) return } l := container.NewRenameLogic(r.Context(), svcCtx) - resp, err := l.Rename(&req) - if err != nil { - httpx.WriteJson(w, resp.Code, resp) - } else { - httpx.WriteJson(w, resp.Code, resp) - } + resp, _ := l.Rename(&req) + writeContainerResponse(w, resp) } } diff --git a/internal/handler/container/response.go b/internal/handler/container/response.go new file mode 100644 index 00000000..5b788a39 --- /dev/null +++ b/internal/handler/container/response.go @@ -0,0 +1,28 @@ +package container + +import ( + "net/http" + + "github.com/onlyLTY/dockerCopilot/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func writeContainerBadRequest(w http.ResponseWriter) { + httpx.WriteJson(w, http.StatusBadRequest, types.Resp{ + Code: http.StatusBadRequest, + Msg: "请求参数错误", + Data: map[string]interface{}{}, + }) +} + +func writeContainerResponse(w http.ResponseWriter, resp *types.Resp) { + if resp == nil || resp.Code < 100 || resp.Code > 599 { + httpx.WriteJson(w, http.StatusInternalServerError, types.Resp{ + Code: http.StatusInternalServerError, + Msg: "内部服务器错误", + Data: map[string]interface{}{}, + }) + return + } + httpx.WriteJson(w, resp.Code, resp) +} diff --git a/internal/handler/container/restarthandler.go b/internal/handler/container/restarthandler.go index 83436e58..e00e2507 100644 --- a/internal/handler/container/restarthandler.go +++ b/internal/handler/container/restarthandler.go @@ -13,15 +13,11 @@ func RestartHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req types.IdReq if err := httpx.Parse(r, &req); err != nil { - httpx.ErrorCtx(r.Context(), w, err) + writeContainerBadRequest(w) return } l := container.NewRestartLogic(r.Context(), svcCtx) - resp, err := l.Restart(&req) - if err != nil { - httpx.WriteJson(w, resp.Code, resp) - } else { - httpx.OkJsonCtx(r.Context(), w, resp) - } + resp, _ := l.Restart(&req) + writeContainerResponse(w, resp) } } diff --git a/internal/handler/container/restorehandler.go b/internal/handler/container/restorehandler.go index a8987f13..fcd010aa 100644 --- a/internal/handler/container/restorehandler.go +++ b/internal/handler/container/restorehandler.go @@ -13,16 +13,12 @@ func RestoreHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req types.ContainerRestoreReq if err := httpx.Parse(r, &req); err != nil { - httpx.ErrorCtx(r.Context(), w, err) + writeContainerBadRequest(w) return } l := container.NewRestoreLogic(r.Context(), svcCtx) - resp, err := l.Restore(&req) - if err != nil { - httpx.ErrorCtx(r.Context(), w, err) - } else { - httpx.OkJsonCtx(r.Context(), w, resp) - } + resp, _ := l.Restore(&req) + writeContainerResponse(w, resp) } } diff --git a/internal/handler/container/starthandler.go b/internal/handler/container/starthandler.go index 62fab1f9..4ddb9100 100644 --- a/internal/handler/container/starthandler.go +++ b/internal/handler/container/starthandler.go @@ -13,16 +13,12 @@ func StartHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req types.IdReq if err := httpx.Parse(r, &req); err != nil { - httpx.ErrorCtx(r.Context(), w, err) + writeContainerBadRequest(w) return } l := container.NewStartLogic(r.Context(), svcCtx) - resp, err := l.Start(&req) - if err != nil { - httpx.WriteJson(w, resp.Code, resp) - } else { - httpx.OkJsonCtx(r.Context(), w, resp) - } + resp, _ := l.Start(&req) + writeContainerResponse(w, resp) } } diff --git a/internal/handler/container/stophandler.go b/internal/handler/container/stophandler.go index 64f07b97..54f76636 100644 --- a/internal/handler/container/stophandler.go +++ b/internal/handler/container/stophandler.go @@ -13,16 +13,12 @@ func StopHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req types.IdReq if err := httpx.Parse(r, &req); err != nil { - httpx.ErrorCtx(r.Context(), w, err) + writeContainerBadRequest(w) return } l := container.NewStopLogic(r.Context(), svcCtx) - resp, err := l.Stop(&req) - if err != nil { - httpx.WriteJson(w, resp.Code, resp) - } else { - httpx.OkJsonCtx(r.Context(), w, resp) - } + resp, _ := l.Stop(&req) + writeContainerResponse(w, resp) } } diff --git a/internal/handler/container/updatehandler.go b/internal/handler/container/updatehandler.go index 3be72180..b2ce6711 100644 --- a/internal/handler/container/updatehandler.go +++ b/internal/handler/container/updatehandler.go @@ -13,16 +13,12 @@ func UpdateHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req types.ContainerUpdateReq if err := httpx.Parse(r, &req); err != nil { - httpx.ErrorCtx(r.Context(), w, err) + writeContainerBadRequest(w) return } l := container.NewUpdateLogic(r.Context(), svcCtx) - resp, err := l.Update(&req) - if err != nil { - httpx.WriteJson(w, resp.Code, resp) - } else { - httpx.OkJsonCtx(r.Context(), w, resp) - } + resp, _ := l.Update(&req) + writeContainerResponse(w, resp) } } diff --git a/internal/handler/icons/obtainhandler.go b/internal/handler/icons/obtainhandler.go index dc191053..4629e3ec 100644 --- a/internal/handler/icons/obtainhandler.go +++ b/internal/handler/icons/obtainhandler.go @@ -1,10 +1,7 @@ package icons import ( - "fmt" "net/http" - "os" - "regexp" "github.com/onlyLTY/dockerCopilot/internal/svc" "github.com/onlyLTY/dockerCopilot/internal/types" @@ -14,43 +11,15 @@ import ( func ObtainHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - jsPath := "/data/config/imageLogos.js" - logx.Infof("Reading icons from: %s", jsPath) - - contentBytes, err := os.ReadFile(jsPath) + icons, err := obtainImageLogos() if err != nil { - if os.IsNotExist(err) { - logx.Info("Config file does not exist, returning empty.") - httpx.OkJsonCtx(r.Context(), w, types.Resp{ - Code: 200, - Msg: "Success", - Data: "{}", - }) - return - } logx.Errorf("Error reading config: %v", err) - httpx.ErrorCtx(r.Context(), w, fmt.Errorf("failed to read config: %v", err)) + httpx.WriteJson(w, http.StatusInternalServerError, types.Resp{ + Code: http.StatusInternalServerError, Msg: "读取图标配置失败", Data: map[string]interface{}{}, + }) return } - content := string(contentBytes) - // fmt.Printf("Config content: %s\n", content) // Uncomment for deep debug - - // 改进的正则表达式:匹配 "key": "value",允许一定的格式变化 - // 使用反引号表示原始字符串。 - re := regexp.MustCompile(`"([^"]+)"\s*:\s*"([^"]+)"`) - matches := re.FindAllStringSubmatch(content, -1) - - icons := make(map[string]string) - for _, match := range matches { - if len(match) == 3 { - key := match[1] - val := match[2] - icons[key] = val - // fmt.Printf("Found icon: %s -> %s\n", key, val) - } - } - logx.Infof("Total icons found: %d", len(icons)) response := struct { diff --git a/internal/handler/icons/paths.go b/internal/handler/icons/paths.go index 644761fc..93e5f667 100644 --- a/internal/handler/icons/paths.go +++ b/internal/handler/icons/paths.go @@ -1,6 +1,51 @@ package icons +import ( + "errors" + "io" + "os" + "path/filepath" +) + +const maxImageLogosConfigSize int64 = 1 << 20 + +var errImageLogosConfigTooLarge = errors.New("image logo config exceeds size limit") + var ( - imageUploadDir = "/data/config/image" - imageLogosPath = "/data/config/imageLogos.js" + imageUploadDir = "/data/config/image" + imageLogosPath = "/data/config/imageLogos.json" + legacyImageLogosPath = "/data/config/imageLogos.js" ) + +func readImageLogosConfig(filePath string) ([]byte, error) { + absolutePath, err := filepath.Abs(filePath) + if err != nil { + return nil, err + } + root, err := os.OpenRoot(filepath.Dir(absolutePath)) + if err != nil { + return nil, err + } + defer root.Close() + name := filepath.Base(absolutePath) + info, err := root.Lstat(name) + if err != nil { + return nil, err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Size() > maxImageLogosConfigSize { + return nil, errImageLogosConfigTooLarge + } + file, err := root.Open(name) + if err != nil { + return nil, err + } + defer file.Close() + content, err := io.ReadAll(io.LimitReader(file, maxImageLogosConfigSize+1)) + if err != nil { + return nil, err + } + if int64(len(content)) > maxImageLogosConfigSize { + return nil, errImageLogosConfigTooLarge + } + return content, nil +} diff --git a/internal/handler/icons/store.go b/internal/handler/icons/store.go new file mode 100644 index 00000000..be716174 --- /dev/null +++ b/internal/handler/icons/store.go @@ -0,0 +1,141 @@ +package icons + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/onlyLTY/dockerCopilot/internal/imageref" +) + +var legacyImageLogoEntryPattern = regexp.MustCompile(`"([^"]+)"\s*:\s*"([^"]+)"`) + +func obtainImageLogos() (map[string]string, error) { + imageLogosMu.Lock() + defer imageLogosMu.Unlock() + return loadImageLogosLocked() +} + +func updateImageLogoMapping(imageName, filename string) (string, error) { + key, err := imageref.RepositoryKey(imageName) + if err != nil { + return "", fmt.Errorf("invalid imageName: %w", err) + } + imageLogosMu.Lock() + defer imageLogosMu.Unlock() + logos, err := loadImageLogosLocked() + if err != nil { + return "", err + } + oldValue := logos[key] + logos[key] = "/src/config/image/" + filename + if err := writeImageLogosLocked(logos); err != nil { + return "", err + } + return storedIconFilename(oldValue), nil +} + +func loadImageLogosLocked() (map[string]string, error) { + content, err := readImageLogosConfig(imageLogosPath) + if err == nil { + var logos map[string]string + if err := json.Unmarshal(content, &logos); err != nil { + return nil, fmt.Errorf("parse image logo config: %w", err) + } + if logos == nil { + logos = make(map[string]string) + } + return logos, nil + } + if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + logos, migrationErr := readLegacyImageLogos() + if migrationErr != nil { + return nil, migrationErr + } + if err := writeImageLogosLocked(logos); err != nil { + return nil, err + } + return logos, nil +} + +func readLegacyImageLogos() (map[string]string, error) { + logos := make(map[string]string) + content, err := readImageLogosConfig(legacyImageLogosPath) + if errors.Is(err, os.ErrNotExist) { + return logos, nil + } + if err != nil { + return nil, err + } + for _, match := range legacyImageLogoEntryPattern.FindAllStringSubmatch(string(content), -1) { + if len(match) != 3 { + continue + } + key, keyErr := imageref.RepositoryKey(match[1]) + if keyErr != nil { + continue + } + logos[key] = match[2] + } + return logos, nil +} + +func writeImageLogosLocked(logos map[string]string) (retErr error) { + content, err := json.MarshalIndent(logos, "", " ") + if err != nil { + return err + } + content = append(content, '\n') + configDir, err := filepath.Abs(filepath.Dir(imageLogosPath)) + if err != nil { + return err + } + if err := os.MkdirAll(configDir, 0o700); err != nil { + return err + } + root, err := os.OpenRoot(configDir) + if err != nil { + return err + } + defer root.Close() + temporary, err := os.CreateTemp(configDir, ".image-logos-*") + if err != nil { + return err + } + temporaryName := filepath.Base(temporary.Name()) + defer func() { + _ = temporary.Close() + _ = root.Remove(temporaryName) + }() + if err := temporary.Chmod(0o600); err != nil { + return err + } + if _, err := temporary.Write(content); err != nil { + return err + } + if err := temporary.Sync(); err != nil { + return err + } + if err := temporary.Close(); err != nil { + return err + } + return root.Rename(temporaryName, filepath.Base(imageLogosPath)) +} + +func storedIconFilename(value string) string { + const prefix = "/src/config/image/" + if !strings.HasPrefix(value, prefix) { + return "" + } + filename := strings.TrimPrefix(value, prefix) + if filename == filepath.Base(filename) && filename != "." { + return filename + } + return "" +} diff --git a/internal/handler/icons/uploadhandler.go b/internal/handler/icons/uploadhandler.go index 253b7139..000a914f 100644 --- a/internal/handler/icons/uploadhandler.go +++ b/internal/handler/icons/uploadhandler.go @@ -1,22 +1,30 @@ package icons import ( + "errors" "fmt" "io" "mime/multipart" "net/http" "os" "path/filepath" - "regexp" "strings" + "sync" "github.com/google/uuid" + "github.com/onlyLTY/dockerCopilot/internal/imageref" "github.com/onlyLTY/dockerCopilot/internal/svc" "github.com/onlyLTY/dockerCopilot/internal/types" "github.com/zeromicro/go-zero/rest/httpx" ) -var imageNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/:-]*$`) +const ( + maxImageFileSize int64 = 2 << 20 + maxUploadRequestSize int64 = maxImageFileSize + (1 << 20) + maxImageNameLength = 255 +) + +var imageLogosMu sync.Mutex var allowedImageTypes = map[string]string{ ".png": "image/png", @@ -28,12 +36,19 @@ var allowedImageTypes = map[string]string{ func UploadHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - // 1. 解析 Multipart 表单 - err := r.ParseMultipartForm(10 << 20) // 10MB 限制 + r.Body = http.MaxBytesReader(w, r.Body, maxUploadRequestSize) + // #nosec G120 -- MaxBytesReader enforces a strict limit on the complete request body. + err := r.ParseMultipartForm(1 << 20) if err != nil { + var maxBytesError *http.MaxBytesError + if errors.As(err, &maxBytesError) { + writeUploadError(w, http.StatusRequestEntityTooLarge, "upload exceeds 2MB limit") + return + } writeUploadError(w, http.StatusBadRequest, "failed to parse form") return } + defer r.MultipartForm.RemoveAll() // 2. 获取文件和 Key file, handler, err := r.FormFile("file") @@ -42,6 +57,10 @@ func UploadHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return } defer file.Close() + if handler.Size > maxImageFileSize { + writeUploadError(w, http.StatusRequestEntityTooLarge, "upload exceeds 2MB limit") + return + } imageNameKey := r.FormValue("imageName") if err := validateImageName(imageNameKey); err != nil { @@ -51,10 +70,16 @@ func UploadHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { // 3. 确保目录存在 (防御性编程) dataPath := imageUploadDir - if err := os.MkdirAll(dataPath, 0o755); err != nil { + if err := os.MkdirAll(dataPath, 0o700); err != nil { writeUploadError(w, http.StatusInternalServerError, "failed to prepare upload dir") return } + dataRoot, err := os.OpenRoot(dataPath) + if err != nil { + writeUploadError(w, http.StatusInternalServerError, "failed to open upload dir") + return + } + defer dataRoot.Close() // 4. 确定文件名 filename, err := generateStoredFilename(file, handler) @@ -63,26 +88,48 @@ func UploadHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return } - dstPath := filepath.Join(dataPath, filename) - dst, err := os.Create(dstPath) + dst, err := dataRoot.OpenFile(filename, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if err != nil { writeUploadError(w, http.StatusInternalServerError, "failed to create file on server") return } - defer dst.Close() - - if _, err := io.Copy(dst, file); err != nil { + copySucceeded := false + defer func() { + _ = dst.Close() + if !copySucceeded { + _ = dataRoot.Remove(filename) + } + }() + + written, err := io.Copy(dst, io.LimitReader(file, maxImageFileSize+1)) + if err != nil || written > maxImageFileSize { + if written > maxImageFileSize { + writeUploadError(w, http.StatusRequestEntityTooLarge, "upload exceeds 2MB limit") + return + } writeUploadError(w, http.StatusInternalServerError, "failed to copy file content") return } + if err := dst.Sync(); err != nil { + writeUploadError(w, http.StatusInternalServerError, "failed to persist file content") + return + } + if err := dst.Close(); err != nil { + writeUploadError(w, http.StatusInternalServerError, "failed to close file") + return + } + copySucceeded = true - // 5. 更新 imageLogos.js - jsPath := imageLogosPath - if err := updateImageLogosJS(jsPath, imageNameKey, filename); err != nil { - _ = os.Remove(dstPath) + // 5. 更新 JSON 映射,并清理被替换的旧文件。 + oldFilename, err := updateImageLogoMapping(imageNameKey, filename) + if err != nil { + _ = dataRoot.Remove(filename) writeUploadError(w, http.StatusInternalServerError, "failed to update config") return } + if oldFilename != "" && oldFilename != filename { + _ = dataRoot.Remove(oldFilename) + } httpx.OkJsonCtx(r.Context(), w, types.Resp{ Code: 200, @@ -93,10 +140,11 @@ func UploadHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { } func validateImageName(imageName string) error { - if imageName == "" { + imageName = strings.TrimSpace(imageName) + if imageName == "" || len(imageName) > maxImageNameLength { return fmt.Errorf("imageName is required") } - if !imageNamePattern.MatchString(imageName) { + if _, err := imageref.RepositoryKey(imageName); err != nil { return fmt.Errorf("invalid imageName") } return nil @@ -133,40 +181,3 @@ func writeUploadError(w http.ResponseWriter, statusCode int, msg string) { Data: map[string]interface{}{}, }) } - -func updateImageLogosJS(filePath, imageName, filename string) error { - // 读取文件 - contentBytes, err := os.ReadFile(filePath) - if err != nil { - return err - } - content := string(contentBytes) - - // 前端使用的容器路径 - containerPath := fmt.Sprintf("/src/config/image/%s", filename) - - if strings.Contains(content, fmt.Sprintf(`"%s"`, imageName)) { - // 更新现有行 - re := regexp.MustCompile(fmt.Sprintf(`"%s"\s*:\s*".*"`, regexp.QuoteMeta(imageName))) - content = re.ReplaceAllString(content, fmt.Sprintf(`"%s": "%s"`, imageName, containerPath)) - } else { - // 插入新行 - // 查找 `export const customImageLogos = {` - startIdx := strings.Index(content, "export const customImageLogos = {") - if startIdx == -1 { - return fmt.Errorf("invalid config format") - } - // 尝试查找右大括号。这里假设它是最后一个右大括号逻辑或者是文件末尾。 - // 一个简单的启发式方法:插入到最后一个 `}` 或 `};` 之前。 - lastBraceIdx := strings.LastIndex(content, "}") - if lastBraceIdx == -1 || lastBraceIdx < startIdx { - return fmt.Errorf("invalid config format, no closing brace") - } - - newLine := fmt.Sprintf(` "%s": "%s",`, imageName, containerPath) - // 插入到最后一个大括号之前 - content = content[:lastBraceIdx] + newLine + "\n" + content[lastBraceIdx:] - } - - return os.WriteFile(filePath, []byte(content), 0644) -} diff --git a/internal/handler/icons/uploadhandler_test.go b/internal/handler/icons/uploadhandler_test.go index 499a2f63..bff20239 100644 --- a/internal/handler/icons/uploadhandler_test.go +++ b/internal/handler/icons/uploadhandler_test.go @@ -2,12 +2,14 @@ package icons import ( "bytes" + "fmt" "io" "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" + "sync" "testing" "github.com/onlyLTY/dockerCopilot/internal/svc" @@ -15,31 +17,20 @@ import ( func TestUploadHandlerRejectsNonImageFiles(t *testing.T) { tempDir := t.TempDir() - jsPath := filepath.Join(tempDir, "imageLogos.js") + jsonPath := filepath.Join(tempDir, "imageLogos.json") imageDir := filepath.Join(tempDir, "image") testFilename := "codex-upload-vuln.json" originalImageUploadDir := imageUploadDir originalImageLogosPath := imageLogosPath + originalLegacyImageLogosPath := legacyImageLogosPath imageUploadDir = imageDir - imageLogosPath = jsPath + imageLogosPath = jsonPath + legacyImageLogosPath = filepath.Join(tempDir, "imageLogos.js") t.Cleanup(func() { imageUploadDir = originalImageUploadDir imageLogosPath = originalImageLogosPath - }) - - originalContent, readErr := os.ReadFile(jsPath) - hadOriginal := readErr == nil - if err := os.WriteFile(jsPath, []byte("// test\nexport const customImageLogos = {\n};\n"), 0o644); err != nil { - t.Fatalf("failed to seed imageLogos.js: %v", err) - } - t.Cleanup(func() { - _ = os.Remove(filepath.Join(imageDir, testFilename)) - if hadOriginal { - _ = os.WriteFile(jsPath, originalContent, 0o644) - } else { - _ = os.Remove(jsPath) - } + legacyImageLogosPath = originalLegacyImageLogosPath }) body := &bytes.Buffer{} @@ -71,3 +62,115 @@ func TestUploadHandlerRejectsNonImageFiles(t *testing.T) { t.Fatalf("expected non-image upload to be rejected without writing file, stat err=%v", err) } } + +func TestValidateImageNameAcceptsDigestReferences(t *testing.T) { + value := "registry.example/team/app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + if err := validateImageName(value); err != nil { + t.Fatalf("valid digest reference was rejected: %v", err) + } + if err := validateImageName("not a valid image"); err == nil { + t.Fatal("invalid image reference was accepted") + } +} + +func TestUploadHandlerRejectsOversizedImage(t *testing.T) { + tempDir := t.TempDir() + originalImageUploadDir := imageUploadDir + originalImageLogosPath := imageLogosPath + originalLegacyImageLogosPath := legacyImageLogosPath + imageUploadDir = filepath.Join(tempDir, "image") + imageLogosPath = filepath.Join(tempDir, "imageLogos.json") + legacyImageLogosPath = filepath.Join(tempDir, "imageLogos.js") + t.Cleanup(func() { + imageUploadDir = originalImageUploadDir + imageLogosPath = originalImageLogosPath + legacyImageLogosPath = originalLegacyImageLogosPath + }) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + fileWriter, err := writer.CreateFormFile("file", "large.png") + if err != nil { + t.Fatal(err) + } + if _, err := fileWriter.Write(bytes.Repeat([]byte{'x'}, int(maxImageFileSize+1))); err != nil { + t.Fatal(err) + } + if err := writer.WriteField("imageName", "large"); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/api/icons", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + recorder := httptest.NewRecorder() + UploadHandler(&svc.ServiceContext{})(recorder, req) + if recorder.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("expected 413, got %d: %s", recorder.Code, recorder.Body.String()) + } +} + +func TestUpdateImageLogosJSONIsConcurrencySafe(t *testing.T) { + tempDir := t.TempDir() + originalImageLogosPath := imageLogosPath + originalLegacyImageLogosPath := legacyImageLogosPath + imageLogosPath = filepath.Join(tempDir, "imageLogos.json") + legacyImageLogosPath = filepath.Join(tempDir, "imageLogos.js") + t.Cleanup(func() { + imageLogosPath = originalImageLogosPath + legacyImageLogosPath = originalLegacyImageLogosPath + }) + var waitGroup sync.WaitGroup + errorsChannel := make(chan error, 10) + for index := 0; index < 10; index++ { + waitGroup.Add(1) + go func(index int) { + defer waitGroup.Done() + _, err := updateImageLogoMapping(fmt.Sprintf("image-%d:latest", index), fmt.Sprintf("%d.png", index)) + errorsChannel <- err + }(index) + } + waitGroup.Wait() + close(errorsChannel) + for err := range errorsChannel { + if err != nil { + t.Fatal(err) + } + } + content, err := os.ReadFile(imageLogosPath) + if err != nil { + t.Fatal(err) + } + for index := 0; index < 10; index++ { + if !bytes.Contains(content, []byte(fmt.Sprintf(`"docker.io/library/image-%d"`, index))) { + t.Fatalf("concurrent update lost image-%d: %s", index, content) + } + } +} + +func TestLegacyImageLogosAreMigratedToJSON(t *testing.T) { + tempDir := t.TempDir() + originalImageLogosPath := imageLogosPath + originalLegacyImageLogosPath := legacyImageLogosPath + imageLogosPath = filepath.Join(tempDir, "imageLogos.json") + legacyImageLogosPath = filepath.Join(tempDir, "imageLogos.js") + t.Cleanup(func() { + imageLogosPath = originalImageLogosPath + legacyImageLogosPath = originalLegacyImageLogosPath + }) + legacy := `export const customImageLogos = {"postgres:17-alpine": "/src/config/image/postgres.png"};` + if err := os.WriteFile(legacyImageLogosPath, []byte(legacy), 0o600); err != nil { + t.Fatal(err) + } + logos, err := obtainImageLogos() + if err != nil { + t.Fatal(err) + } + if logos["docker.io/library/postgres"] != "/src/config/image/postgres.png" { + t.Fatalf("legacy mapping was not normalized: %+v", logos) + } + if _, err := os.Stat(imageLogosPath); err != nil { + t.Fatalf("JSON migration was not persisted: %v", err) + } +} diff --git a/internal/handler/image/removehandler.go b/internal/handler/image/removehandler.go index c06829e2..1628461a 100644 --- a/internal/handler/image/removehandler.go +++ b/internal/handler/image/removehandler.go @@ -13,16 +13,14 @@ func RemoveHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req types.RemoveImageReq if err := httpx.Parse(r, &req); err != nil { - httpx.ErrorCtx(r.Context(), w, err) + httpx.WriteJson(w, http.StatusBadRequest, types.Resp{ + Code: http.StatusBadRequest, Msg: "请求参数错误", Data: map[string]interface{}{}, + }) return } l := image.NewRemoveLogic(r.Context(), svcCtx) - resp, err := l.Remove(&req) - if err != nil { - httpx.WriteJson(w, resp.Code, resp) - } else { - httpx.WriteJson(w, resp.Code, resp) - } + resp, _ := l.Remove(&req) + httpx.WriteJson(w, resp.Code, resp) } } diff --git a/internal/handler/progress/getprogresshandler.go b/internal/handler/progress/getprogresshandler.go index 692ab6f9..56e38a7f 100644 --- a/internal/handler/progress/getprogresshandler.go +++ b/internal/handler/progress/getprogresshandler.go @@ -13,16 +13,14 @@ func GetProgressHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req types.GetProgressReq if err := httpx.Parse(r, &req); err != nil { - httpx.ErrorCtx(r.Context(), w, err) + httpx.WriteJson(w, http.StatusBadRequest, types.Resp{ + Code: http.StatusBadRequest, Msg: "请求参数错误", Data: map[string]interface{}{}, + }) return } l := progress.NewGetProgressLogic(r.Context(), svcCtx) - resp, err := l.GetProgress(&req) - if err != nil { - httpx.WriteJson(w, resp.Code, resp) - } else { - httpx.OkJsonCtx(r.Context(), w, resp) - } + resp, _ := l.GetProgress(&req) + httpx.WriteJson(w, resp.Code, resp) } } diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 5a84662d..2ec16527 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -5,6 +5,7 @@ package handler import ( "net/http" + "time" auth "github.com/onlyLTY/dockerCopilot/internal/handler/auth" container "github.com/onlyLTY/dockerCopilot/internal/handler/container" @@ -67,12 +68,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Handler: container.UpdateHandler(serverCtx), }, { - Method: http.MethodGet, + Method: http.MethodPost, Path: "/container/backup", Handler: container.BackupHandler(serverCtx), }, { - Method: http.MethodGet, + Method: http.MethodPost, Path: "/container/backup2compose", Handler: container.Backup2composeHandler(serverCtx), }, @@ -91,6 +92,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/container/listBackups", Handler: container.ListBackupsHandler(serverCtx), }, + { + Method: http.MethodGet, + Path: "/container/backups/download", + Handler: container.DownloadBackupHandler(serverCtx), + }, { Method: http.MethodGet, Path: "/containers", @@ -99,6 +105,7 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { }, rest.WithJwt(serverCtx.Config.Auth.AccessSecret), rest.WithPrefix("/api"), + rest.WithTimeout(2*time.Minute), ) server.AddRoutes( @@ -116,6 +123,7 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { }, rest.WithJwt(serverCtx.Config.Auth.AccessSecret), rest.WithPrefix("/api/icons"), + rest.WithMaxBytes(3<<20), ) server.AddRoutes( @@ -162,5 +170,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { }, rest.WithJwt(serverCtx.Config.Auth.AccessSecret), rest.WithPrefix("/api"), + rest.WithTimeout(5*time.Minute), ) } diff --git a/internal/handler/version/versionhandler.go b/internal/handler/version/versionhandler.go index dcef2f99..bc361a33 100644 --- a/internal/handler/version/versionhandler.go +++ b/internal/handler/version/versionhandler.go @@ -13,16 +13,14 @@ func VersionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req types.VersionReq if err := httpx.Parse(r, &req); err != nil { - httpx.ErrorCtx(r.Context(), w, err) + httpx.WriteJson(w, http.StatusBadRequest, types.Resp{ + Code: http.StatusBadRequest, Msg: "请求参数错误", Data: map[string]interface{}{}, + }) return } l := version.NewVersionLogic(r.Context(), svcCtx) - resp, err := l.Version(&req) - if err != nil { - httpx.WriteJson(w, resp.Code, resp) - } else { - httpx.WriteJson(w, resp.Code, resp) - } + resp, _ := l.Version(&req) + httpx.WriteJson(w, resp.Code, resp) } } diff --git a/internal/imageref/reference.go b/internal/imageref/reference.go new file mode 100644 index 00000000..0db8c969 --- /dev/null +++ b/internal/imageref/reference.go @@ -0,0 +1,52 @@ +package imageref + +import ( + "fmt" + "strings" + + ref "github.com/distribution/reference" +) + +type TaggedReference struct { + Normalized string + Repository string + Familiar string + Tag string +} + +// RepositoryKey returns a canonical repository name without a tag or digest. +// Docker Hub short names are normalized to docker.io/library/ so the +// same image cannot acquire several cache or icon keys. +func RepositoryKey(value string) (string, error) { + parsed, err := ref.ParseNormalizedNamed(strings.TrimSpace(value)) + if err != nil { + return "", err + } + return ref.TrimNamed(parsed).Name(), nil +} + +func ParseTagged(value string) (TaggedReference, error) { + parsed, err := ref.ParseDockerRef(strings.TrimSpace(value)) + if err != nil { + return TaggedReference{}, err + } + tagged, ok := parsed.(ref.NamedTagged) + if !ok { + return TaggedReference{}, fmt.Errorf("镜像引用 %q 没有 tag", value) + } + trimmed := ref.TrimNamed(tagged) + return TaggedReference{ + Normalized: tagged.String(), + Repository: trimmed.Name(), + Familiar: ref.FamiliarName(trimmed), + Tag: tagged.Tag(), + }, nil +} + +func CacheKey(value string) string { + parsed, err := ParseTagged(value) + if err != nil { + return strings.TrimSpace(value) + } + return parsed.Normalized +} diff --git a/internal/imageref/reference_test.go b/internal/imageref/reference_test.go new file mode 100644 index 00000000..9db3229e --- /dev/null +++ b/internal/imageref/reference_test.go @@ -0,0 +1,34 @@ +package imageref + +import "testing" + +func TestParseTaggedSupportsRegistryPort(t *testing.T) { + parsed, err := ParseTagged("registry.example:5000/team/postgres:17-alpine") + if err != nil { + t.Fatal(err) + } + if parsed.Normalized != "registry.example:5000/team/postgres:17-alpine" || parsed.Repository != "registry.example:5000/team/postgres" || parsed.Tag != "17-alpine" { + t.Fatalf("unexpected parsed reference: %+v", parsed) + } +} + +func TestCacheKeyNormalizesDockerHubReference(t *testing.T) { + if got := CacheKey("postgres:17-alpine"); got != "docker.io/library/postgres:17-alpine" { + t.Fatalf("unexpected cache key %q", got) + } +} + +func TestRepositoryKeyDropsTagAndDigest(t *testing.T) { + for _, value := range []string{ + "postgres:17-alpine", + "docker.io/library/postgres@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + } { + got, err := RepositoryKey(value) + if err != nil { + t.Fatal(err) + } + if got != "docker.io/library/postgres" { + t.Fatalf("unexpected repository key %q for %q", got, value) + } + } +} diff --git a/internal/logic/auth/loginlogic.go b/internal/logic/auth/loginlogic.go index 32ae54b9..82bff0fb 100644 --- a/internal/logic/auth/loginlogic.go +++ b/internal/logic/auth/loginlogic.go @@ -2,14 +2,20 @@ package auth import ( "context" + "crypto/sha256" + "crypto/subtle" "errors" - "github.com/golang-jwt/jwt" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" "github.com/onlyLTY/dockerCopilot/internal/svc" "github.com/onlyLTY/dockerCopilot/internal/types" "github.com/zeromicro/go-zero/core/logx" - "time" ) +const maxTokenLifetime = 24 * time.Hour + type LoginLogic struct { logx.Logger ctx context.Context @@ -30,15 +36,21 @@ func NewLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginLogic func (l *LoginLogic) Login(req *types.LoginReq) (resp *types.Resp, err error) { resp = &types.Resp{} - if l.svcCtx.Config.Auth.AccessSecret != req.SecretKey { + expectedSecret := sha256.Sum256([]byte(l.svcCtx.Config.Auth.AccessSecret)) + providedSecret := sha256.Sum256([]byte(req.SecretKey)) + if subtle.ConstantTimeCompare(expectedSecret[:], providedSecret[:]) != 1 { resp.Code = 401 resp.Msg = "无效的secretKey" resp.Data = JwtResponse{Jwt: ""} return resp, errors.New("无效的secretKey") } + lifetime := time.Duration(l.svcCtx.Config.Auth.AccessExpire) * time.Second + if lifetime <= 0 || lifetime > maxTokenLifetime { + lifetime = maxTokenLifetime + } jwtToken, err := l.getJwtToken(l.svcCtx.Config.Auth.AccessSecret, time.Now().Unix(), - l.svcCtx.Config.Auth.AccessExpire, + int64(lifetime/time.Second), ) if err != nil { resp.Code = 500 @@ -56,6 +68,10 @@ func (l *LoginLogic) getJwtToken(secretKey string, iat, seconds int64) (string, claims := make(jwt.MapClaims) claims["iat"] = iat claims["exp"] = iat + seconds + claims["nbf"] = iat + claims["iss"] = "docker-copilot" + claims["sub"] = "docker-administrator" + claims["jti"] = uuid.NewString() token := jwt.New(jwt.SigningMethodHS256) token.Claims = claims return token.SignedString([]byte(secretKey)) diff --git a/internal/logic/container/backup2composelogic.go b/internal/logic/container/backup2composelogic.go index eaad96e1..6f0efaec 100644 --- a/internal/logic/container/backup2composelogic.go +++ b/internal/logic/container/backup2composelogic.go @@ -28,9 +28,14 @@ func (l *Backup2composeLogic) Backup2compose() (resp *types.Resp, err error) { resp = &types.Resp{} err = utiles.Backup2Compose(l.svcCtx) if err != nil { - return nil, err + l.Errorf("Compose 备份失败: %v", err) + resp.Code = 500 + resp.Msg = "Compose 备份失败" + resp.Data = map[string]interface{}{} + return resp, err } resp.Code = 200 resp.Msg = "success" - return + resp.Data = map[string]interface{}{} + return resp, nil } diff --git a/internal/logic/container/containerslistlogic.go b/internal/logic/container/containerslistlogic.go index a8c44db2..b55983b2 100644 --- a/internal/logic/container/containerslistlogic.go +++ b/internal/logic/container/containerslistlogic.go @@ -3,6 +3,7 @@ package container import ( "context" "github.com/onlyLTY/dockerCopilot/internal/utiles" + "strings" "time" "github.com/onlyLTY/dockerCopilot/internal/svc" @@ -18,14 +19,16 @@ type ContainersListLogic struct { } type Info struct { - Id string `json:"id"` - Status string `json:"status"` - Name string `json:"name"` - UsingImage string `json:"usingImage"` - CreateImage string `json:"createImage"` - CreateTime string `json:"createTime"` - RunningTime string `json:"runningTime"` - HaveUpdate bool `json:"haveUpdate"` + Id string `json:"id"` + Status string `json:"status"` + Name string `json:"name"` + UsingImage string `json:"usingImage"` + CreateImage string `json:"createImage"` + CreateTime string `json:"createTime"` + RunningTime string `json:"runningTime"` + HaveUpdate bool `json:"haveUpdate"` + IsSelf bool `json:"isSelf"` + IconHints []string `json:"iconHints,omitempty"` } func NewContainersListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ContainersListLogic { @@ -47,15 +50,15 @@ func (l *ContainersListLogic) ContainersList() (resp *types.Resp, err error) { return resp, err } resp.Msg = "success" + resp.Code = 200 var containerInfoList []Info list = utiles.CheckImageUpdate(l.svcCtx, list) for _, v := range list { var containerInfo Info containerInfo.Id = v.ID containerInfo.Status = v.State - if len(v.Names) > 0 { - ContainerName := v.Names[0][1:] - containerInfo.Name = ContainerName + if len(v.Names) > 0 && strings.TrimPrefix(v.Names[0], "/") != "" { + containerInfo.Name = strings.TrimPrefix(v.Names[0], "/") } else { containerInfo.Name = "get container name error" l.Error("get container name error" + v.ID) @@ -66,16 +69,21 @@ func (l *ContainersListLogic) ContainersList() (resp *types.Resp, err error) { containerInfo.UsingImage = v.ImageID l.Error("image dont have name" + v.ID) } - containerInspect, err := utiles.GetContainerInspect(l.svcCtx, v.ID) - if err != nil { - containerInfo.CreateImage = "" - l.Error("get image name error" + v.ID) - } - containerInfo.CreateImage = containerInspect.Config.Image + containerInfo.CreateImage = containerInfo.UsingImage t := time.Unix(v.Created, 0) containerInfo.CreateTime = t.Format("2006-01-02 15:04:05") containerInfo.RunningTime = v.Status containerInfo.HaveUpdate = v.Update + containerInfo.IsSelf = utiles.IsSelfContainerID(v.ID) + for _, key := range []string{ + "org.opencontainers.image.title", + "org.opencontainers.image.source", + "com.docker.compose.service", + } { + if hint := strings.TrimSpace(v.Labels[key]); hint != "" { + containerInfo.IconHints = append(containerInfo.IconHints, hint) + } + } containerInfoList = append(containerInfoList, containerInfo) } resp.Data = containerInfoList diff --git a/internal/logic/container/delrestorelogic.go b/internal/logic/container/delrestorelogic.go index 6d502685..0e617fe1 100644 --- a/internal/logic/container/delrestorelogic.go +++ b/internal/logic/container/delrestorelogic.go @@ -3,6 +3,7 @@ package container import ( "context" "os" + "path/filepath" "github.com/onlyLTY/dockerCopilot/internal/svc" "github.com/onlyLTY/dockerCopilot/internal/types" @@ -34,7 +35,15 @@ func (l *DelRestoreLogic) DelRestore(req *types.DelContainerBackupReq) (resp *ty resp.Data = map[string]interface{}{} return resp, nil } - err = os.Remove(fullPath) + backupRoot, openErr := os.OpenRoot(filepath.Dir(fullPath)) + if openErr != nil { + resp.Code = 500 + resp.Msg = "删除失败" + resp.Data = map[string]interface{}{} + return resp, nil + } + defer backupRoot.Close() + err = backupRoot.Remove(filepath.Base(fullPath)) if err != nil { resp.Code = 400 resp.Msg = "删除失败" diff --git a/internal/logic/container/operation.go b/internal/logic/container/operation.go new file mode 100644 index 00000000..45585f1b --- /dev/null +++ b/internal/logic/container/operation.go @@ -0,0 +1,28 @@ +package container + +import ( + "errors" + "strings" + + "github.com/onlyLTY/dockerCopilot/internal/svc" + "github.com/onlyLTY/dockerCopilot/internal/types" +) + +var errContainerOperationInProgress = errors.New("该容器正在执行其他操作,或系统正在恢复备份,请稍后重试") + +func beginContainerOperation(serviceContext *svc.ServiceContext, resp *types.Resp, rawID, operation string) (string, error) { + containerID := strings.TrimSpace(rawID) + if containerID == "" { + resp.Code = 400 + resp.Msg = "容器 ID 不能为空" + resp.Data = map[string]interface{}{} + return "", errors.New(resp.Msg) + } + if !serviceContext.BeginContainerOperation(containerID, operation) { + resp.Code = 409 + resp.Msg = errContainerOperationInProgress.Error() + resp.Data = map[string]interface{}{} + return "", errContainerOperationInProgress + } + return containerID, nil +} diff --git a/internal/logic/container/renamelogic.go b/internal/logic/container/renamelogic.go index d95da18b..0d12b2dc 100644 --- a/internal/logic/container/renamelogic.go +++ b/internal/logic/container/renamelogic.go @@ -26,7 +26,19 @@ func NewRenameLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RenameLogi func (l *RenameLogic) Rename(req *types.ContainerRenameReq) (resp *types.Resp, err error) { resp = &types.Resp{} - err = utiles.RenameContainer(l.svcCtx, req.Id, req.NewName) + containerID, err := beginContainerOperation(l.svcCtx, resp, req.Id, "rename") + if err != nil { + return resp, err + } + defer l.svcCtx.EndContainerOperation(containerID) + newName, err := validateContainerName(req.NewName) + if err != nil { + resp.Code = 400 + resp.Msg = err.Error() + resp.Data = map[string]interface{}{} + return resp, err + } + err = utiles.RenameContainer(l.svcCtx, containerID, newName) if err != nil { resp.Code = 400 resp.Msg = err.Error() diff --git a/internal/logic/container/restartlogic.go b/internal/logic/container/restartlogic.go index e265cefd..77b63b9a 100644 --- a/internal/logic/container/restartlogic.go +++ b/internal/logic/container/restartlogic.go @@ -26,7 +26,12 @@ func NewRestartLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RestartLo func (l *RestartLogic) Restart(req *types.IdReq) (resp *types.Resp, err error) { resp = &types.Resp{} - err = utiles.RestartContainer(l.svcCtx, req.Id) + containerID, err := beginContainerOperation(l.svcCtx, resp, req.Id, "restart") + if err != nil { + return resp, err + } + defer l.svcCtx.EndContainerOperation(containerID) + err = utiles.RestartContainer(l.svcCtx, containerID) if err != nil { resp.Code = 400 resp.Msg = err.Error() diff --git a/internal/logic/container/restorelogic.go b/internal/logic/container/restorelogic.go index 8334681f..196bf31c 100644 --- a/internal/logic/container/restorelogic.go +++ b/internal/logic/container/restorelogic.go @@ -2,6 +2,7 @@ package container import ( "context" + "errors" "fmt" "github.com/google/uuid" "github.com/onlyLTY/dockerCopilot/internal/utiles" @@ -13,6 +14,8 @@ import ( "github.com/zeromicro/go-zero/core/logx" ) +var errRestoreInProgress = errors.New("已有恢复任务正在执行") + type RestoreLogic struct { logx.Logger ctx context.Context @@ -46,11 +49,26 @@ func (l *RestoreLogic) Restore(req *types.ContainerRestoreReq) (resp *types.Resp resp.Data = map[string]interface{}{} return resp, err } + if !l.svcCtx.BeginRestore() { + resp.Code = 409 + resp.Msg = errRestoreInProgress.Error() + resp.Data = map[string]interface{}{} + return resp, errRestoreInProgress + } + l.svcCtx.UpdateProgress(taskID, svc.TaskProgress{ + TaskID: taskID, Name: "恢复容器", Message: "任务已创建", + DetailMsg: "等待开始恢复", Status: svc.TaskStatusRunning, + }) go func() { + defer l.svcCtx.EndRestore() // Catch any panic and log the error defer func() { if r := recover(); r != nil { l.Errorf("Recovered from panic in restoreContainer: %v", r) + l.svcCtx.UpdateProgress(taskID, svc.TaskProgress{ + TaskID: taskID, Name: "恢复容器", Message: "恢复任务异常终止", + DetailMsg: fmt.Sprint(r), IsDone: true, Status: svc.TaskStatusFailed, + }) } }() err := utiles.RestoreContainer(l.svcCtx, fileName, taskID) diff --git a/internal/logic/container/startlogic.go b/internal/logic/container/startlogic.go index cbf2adf2..13713488 100644 --- a/internal/logic/container/startlogic.go +++ b/internal/logic/container/startlogic.go @@ -25,7 +25,12 @@ func NewStartLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartLogic func (l *StartLogic) Start(req *types.IdReq) (resp *types.Resp, err error) { resp = &types.Resp{} - err = utiles.StartContainer(l.svcCtx, req.Id) + containerID, err := beginContainerOperation(l.svcCtx, resp, req.Id, "start") + if err != nil { + return resp, err + } + defer l.svcCtx.EndContainerOperation(containerID) + err = utiles.StartContainer(l.svcCtx, containerID) if err != nil { resp.Code = 400 resp.Msg = err.Error() diff --git a/internal/logic/container/stoplogic.go b/internal/logic/container/stoplogic.go index dcc10387..9a72333d 100644 --- a/internal/logic/container/stoplogic.go +++ b/internal/logic/container/stoplogic.go @@ -26,7 +26,12 @@ func NewStopLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StopLogic { func (l *StopLogic) Stop(req *types.IdReq) (resp *types.Resp, err error) { resp = &types.Resp{} - err = utiles.StopContainer(l.svcCtx, req.Id) + containerID, err := beginContainerOperation(l.svcCtx, resp, req.Id, "stop") + if err != nil { + return resp, err + } + defer l.svcCtx.EndContainerOperation(containerID) + err = utiles.StopContainer(l.svcCtx, containerID) if err != nil { resp.Code = 400 resp.Msg = err.Error() diff --git a/internal/logic/container/updatelogic.go b/internal/logic/container/updatelogic.go index 4feb0bd5..2d961bf0 100644 --- a/internal/logic/container/updatelogic.go +++ b/internal/logic/container/updatelogic.go @@ -2,14 +2,24 @@ package container import ( "context" + "errors" + "fmt" + "strings" + "time" + "github.com/google/uuid" + "github.com/onlyLTY/dockerCopilot/internal/imageref" "github.com/onlyLTY/dockerCopilot/internal/svc" "github.com/onlyLTY/dockerCopilot/internal/types" "github.com/onlyLTY/dockerCopilot/internal/utiles" "github.com/zeromicro/go-zero/core/logx" - "os" ) +var errContainerUpdateInProgress = errors.New("该容器正在更新,请勿重复提交") +var errSelfContainerUpdate = errors.New("当前 Docker Copilot 不能在自身容器内执行原地更新,请拉取新镜像后由 Docker Compose 重新创建") + +const containerUpdateQueueTimeout = 30 * time.Minute + type UpdateLogic struct { logx.Logger ctx context.Context @@ -26,17 +36,66 @@ func NewUpdateLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateLogi func (l *UpdateLogic) Update(req *types.ContainerUpdateReq) (resp *types.Resp, err error) { resp = &types.Resp{} + containerID := strings.TrimSpace(req.Id) + containerName, err := validateContainerName(req.ContainerName) + if err != nil || containerID == "" { + resp.Code = 400 + resp.Msg = "容器参数格式错误" + resp.Data = map[string]interface{}{} + return resp, errors.New(resp.Msg) + } + imageReference, err := imageref.ParseTagged(strings.TrimSpace(req.ImageNameAndTag)) + if err != nil { + resp.Code = 400 + resp.Msg = "镜像引用格式错误" + resp.Data = map[string]interface{}{} + return resp, err + } + if utiles.IsSelfContainerID(containerID) { + resp.Code = 409 + resp.Msg = errSelfContainerUpdate.Error() + resp.Data = map[string]interface{}{} + return resp, errSelfContainerUpdate + } + if !l.svcCtx.BeginContainerUpdate(containerID) { + resp.Code = 409 + resp.Msg = errContainerUpdateInProgress.Error() + resp.Data = map[string]interface{}{} + return resp, errContainerUpdateInProgress + } taskID := uuid.New().String() + l.svcCtx.UpdateProgress(taskID, svc.TaskProgress{ + TaskID: taskID, Name: containerName, Message: "任务已创建", + DetailMsg: "等待开始更新", Status: svc.TaskStatusRunning, + }) go func() { + defer l.svcCtx.EndContainerUpdate(containerID) // Catch any panic and log the error defer func() { if r := recover(); r != nil { l.Errorf("Recovered from panic in UpdateContainer: %v", r) + l.svcCtx.UpdateProgress(taskID, svc.TaskProgress{ + TaskID: taskID, Name: containerName, Message: "更新任务异常终止", + DetailMsg: fmt.Sprint(r), IsDone: true, Status: svc.TaskStatusFailed, + }) } }() - imageNameAndTag := req.ImageNameAndTag - delOldContainer := os.Getenv("DelOldContainer") != "false" - err := utiles.UpdateContainer(l.svcCtx, req.Id, req.ContainerName, imageNameAndTag, delOldContainer, taskID) + l.svcCtx.UpdateProgress(taskID, svc.TaskProgress{ + TaskID: taskID, Name: containerName, Message: "等待更新执行槽", + DetailMsg: "最多同时更新两个容器", Status: svc.TaskStatusRunning, + }) + queueContext, cancelQueue := context.WithTimeout(context.Background(), containerUpdateQueueTimeout) + acquired := l.svcCtx.AcquireContainerUpdateSlot(queueContext) + cancelQueue() + if !acquired { + l.svcCtx.UpdateProgress(taskID, svc.TaskProgress{ + TaskID: taskID, Name: containerName, Message: "更新任务已取消", + DetailMsg: "等待执行槽时任务被取消", IsDone: true, Status: svc.TaskStatusFailed, + }) + return + } + defer l.svcCtx.ReleaseContainerUpdateSlot() + err := utiles.UpdateContainer(l.svcCtx, containerID, containerName, imageReference.Normalized, req.DelOldContainer, taskID) if err != nil { l.Errorf("Error in UpdateContainer: %v", err) } diff --git a/internal/logic/container/validation.go b/internal/logic/container/validation.go new file mode 100644 index 00000000..f980d190 --- /dev/null +++ b/internal/logic/container/validation.go @@ -0,0 +1,17 @@ +package container + +import ( + "errors" + "regexp" + "strings" +) + +var containerNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`) + +func validateContainerName(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" || len(name) > 128 || !containerNamePattern.MatchString(name) { + return "", errors.New("容器名称格式错误") + } + return name, nil +} diff --git a/internal/logic/container/validation_test.go b/internal/logic/container/validation_test.go new file mode 100644 index 00000000..c9949189 --- /dev/null +++ b/internal/logic/container/validation_test.go @@ -0,0 +1,16 @@ +package container + +import "testing" + +func TestValidateContainerName(t *testing.T) { + for _, valid := range []string{"postgres", "app-1", "team_service.2"} { + if _, err := validateContainerName(valid); err != nil { + t.Fatalf("valid name %q rejected: %v", valid, err) + } + } + for _, invalid := range []string{"", "/postgres", "bad name", "../escape"} { + if _, err := validateContainerName(invalid); err == nil { + t.Fatalf("invalid name %q accepted", invalid) + } + } +} diff --git a/internal/logic/image/removelogic.go b/internal/logic/image/removelogic.go index 21fe84d4..5767bc63 100644 --- a/internal/logic/image/removelogic.go +++ b/internal/logic/image/removelogic.go @@ -2,11 +2,11 @@ package image import ( "context" - "github.com/onlyLTY/dockerCopilot/internal/utiles" "strings" "github.com/onlyLTY/dockerCopilot/internal/svc" "github.com/onlyLTY/dockerCopilot/internal/types" + "github.com/onlyLTY/dockerCopilot/internal/utiles" "github.com/zeromicro/go-zero/core/logx" ) @@ -27,11 +27,8 @@ func NewRemoveLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RemoveLogi func (l *RemoveLogic) Remove(req *types.RemoveImageReq) (resp *types.Resp, err error) { resp = &types.Resp{} - var imageId = req.Id - if strings.HasPrefix(imageId, "sha256:") { - imageId = strings.TrimPrefix(imageId, "sha256:") - } - err = utiles.RemoveImage(l.svcCtx, req.Id, req.Force) + imageID := strings.TrimPrefix(req.Id, "sha256:") + err = utiles.RemoveImage(l.svcCtx, imageID, req.Force) if err != nil { resp.Code = 409 resp.Msg = err.Error() diff --git a/internal/logic/progress/getprogresslogic.go b/internal/logic/progress/getprogresslogic.go index 9746f700..23f2a9d2 100644 --- a/internal/logic/progress/getprogresslogic.go +++ b/internal/logic/progress/getprogresslogic.go @@ -42,6 +42,7 @@ func (l *GetProgressLogic) GetProgress(req *types.GetProgressReq) (resp *types.R "name": progress.Name, "detailMsg": progress.DetailMsg, "isDone": progress.IsDone, + "status": progress.Status, } return resp, nil } diff --git a/internal/logic/version/updateprogramlogic.go b/internal/logic/version/updateprogramlogic.go index 07c1c34a..d51e21b8 100644 --- a/internal/logic/version/updateprogramlogic.go +++ b/internal/logic/version/updateprogramlogic.go @@ -2,6 +2,7 @@ package version import ( "context" + "errors" "github.com/onlyLTY/dockerCopilot/internal/svc" "github.com/onlyLTY/dockerCopilot/internal/types" "github.com/onlyLTY/dockerCopilot/internal/utiles" @@ -26,18 +27,25 @@ func NewUpdateProgramLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Upd func (l *UpdateProgramLogic) UpdateProgram() (resp *types.Resp, err error) { resp = &types.Resp{} - err = utiles.UpdateProgram(l.svcCtx) + err = utiles.UpdateProgram(l.ctx) if err != nil { + if errors.Is(err, utiles.ErrAlreadyLatest) || errors.Is(err, utiles.ErrImageManagedUpdate) || errors.Is(err, utiles.ErrRemoteVersionNotNewer) { + resp.Code = 409 + resp.Msg = err.Error() + resp.Data = map[string]interface{}{} + return resp, err + } resp.Code = 500 - resp.Msg = err.Error() + resp.Msg = "程序更新失败,请查看服务日志" resp.Data = map[string]interface{}{} + l.Errorf("程序更新失败: %v", err) return resp, err } resp.Code = 200 resp.Msg = "success" go func() { - time.Sleep(10 * time.Second) - os.Exit(1) + time.Sleep(2 * time.Second) + os.Exit(0) }() resp.Data = map[string]interface{}{} return resp, nil diff --git a/internal/logic/version/versionlogic.go b/internal/logic/version/versionlogic.go index 4cf7c9d8..9f6ee28e 100644 --- a/internal/logic/version/versionlogic.go +++ b/internal/logic/version/versionlogic.go @@ -28,18 +28,24 @@ func NewVersionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *VersionLo func (l *VersionLogic) Version(req *types.VersionReq) (resp *types.Resp, err error) { resp = &types.Resp{} if req.Type == "local" { + updateMode := "binary" + if utiles.BinarySelfUpdateDisabled() { + updateMode = "container" + } resp.Code = 200 resp.Msg = "success" resp.Data = map[string]string{ - "version": config.Version, - "buildDate": config.BuildDate, + "version": config.Version, + "buildDate": config.BuildDate, + "updateMode": updateMode, } return resp, nil } else if req.Type == "remote" { - remoteVersion, err := utiles.GetRemoteVersion() + remoteVersion, err := utiles.GetRemoteVersion(l.ctx) if err != nil { - resp.Code = 50001 - resp.Msg = "获取版本错误" + err.Error() + l.Errorf("获取远端版本失败: %v", err) + resp.Code = 502 + resp.Msg = "获取远端版本失败" resp.Data = map[string]string{ "remoteVersion": config.Version, } diff --git a/internal/module/auth.go b/internal/module/auth.go index 18eecb4d..c8a38695 100644 --- a/internal/module/auth.go +++ b/internal/module/auth.go @@ -1,157 +1,209 @@ package module import ( + "context" "encoding/json" "errors" "fmt" - ref "github.com/distribution/reference" - "github.com/onlyLTY/dockerCopilot/internal/types" - "github.com/zeromicro/go-zero/core/logx" "io" "net/http" "net/url" "strings" "time" + + ref "github.com/distribution/reference" + "github.com/onlyLTY/dockerCopilot/internal/types" ) const ChallengeHeader = "WWW-Authenticate" + const ( DefaultRegistryDomain = "docker.io" - DefaultRegistryHost = "index.docker.io" + DefaultRegistryHost = "registry-1.docker.io" + maxRegistryBodySize = 1 << 20 ) -var DefaultAcceleratorHostList = []string{"docker.1ms.run", "docker.m.daocloud.io", - "docker.1panel.top", "docker.1panel.live", "proxy.1panel.live", "dockerproxy.1panel.live", "docker.1panel.dev", - "docker.anye.in", "hub.rat.dev", "docker.amingg.com"} +var registryHTTPClient = secureRegistryHTTPClient(15 * time.Second) -func GetToken(image types.Image, registryAuth string) (string, error) { - logx.Infof("image name %s", image.ImageName) - normalizedRef, err := ref.ParseNormalizedNamed(image.ImageName) +func secureRegistryHTTPClient(timeout time.Duration) *http.Client { + return &http.Client{ + Timeout: timeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if req.URL.Scheme != "https" { + return errors.New("拒绝 registry 重定向到非 HTTPS 地址") + } + if len(via) >= 10 { + return errors.New("registry 重定向次数过多") + } + return nil + }, + } +} + +func GetToken(ctx context.Context, image types.Image, registryAuth string) (string, error) { + imageReference, err := referenceForImage(image) if err != nil { return "", err } - - URL := GetChallengeURL(normalizedRef) - - var req *http.Request - if req, err = GetChallengeRequest(URL); err != nil { + normalizedRef, err := ref.ParseNormalizedNamed(imageReference) + if err != nil { return "", err } - - client := &http.Client{} - var res *http.Response - if res, err = client.Do(req); err != nil { + challengeURL, err := GetChallengeURL(normalizedRef) + if err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, challengeURL.String(), nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "*/*") + req.Header.Set("User-Agent", "Docker-Copilot") + res, err := registryHTTPClient.Do(req) + if err != nil { return "", err } - defer func(Body io.ReadCloser) { - err := Body.Close() - if err != nil { - logx.Error("GetToken关闭Body失败" + err.Error()) + defer res.Body.Close() + if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusUnauthorized { + return "", fmt.Errorf("registry challenge 返回 %s", res.Status) + } + challenge := strings.TrimSpace(res.Header.Get(ChallengeHeader)) + if challenge == "" { + if res.StatusCode == http.StatusOK { + return "", nil } - }(res.Body) - v := res.Header.Get(ChallengeHeader) - - challenge := strings.ToLower(v) - if strings.HasPrefix(challenge, "basic") { + return "", errors.New("registry 未返回 WWW-Authenticate") + } + scheme, _, _ := strings.Cut(challenge, " ") + switch strings.ToLower(scheme) { + case "basic": if registryAuth == "" { - return "", fmt.Errorf("no credentials available") + return "", errors.New("私有 registry 需要凭据") } - - return fmt.Sprintf("Basic %s", registryAuth), nil - } - if strings.HasPrefix(challenge, "bearer") { - return GetBearerHeader(challenge, normalizedRef, registryAuth) - } - - return "", errors.New("unsupported challenge type from registry") -} - -func GetChallengeRequest(URL url.URL) (*http.Request, error) { - req, err := http.NewRequest("GET", URL.String(), nil) - if err != nil { - return nil, err + return "Basic " + registryAuth, nil + case "bearer": + return GetBearerHeader(ctx, challenge, normalizedRef, registryAuth) + default: + return "", fmt.Errorf("不支持的 registry 鉴权方式 %q", scheme) } - req.Header.Set("Accept", "*/*") - req.Header.Set("User-Agent", "Watchtower (Docker)") - return req, nil } -func GetBearerHeader(challenge string, imageRef ref.Named, registryAuth string) (string, error) { - client := http.Client{} +func GetBearerHeader(ctx context.Context, challenge string, imageRef ref.Named, registryAuth string) (string, error) { authURL, err := GetAuthURL(challenge, imageRef) - if err != nil { return "", err } - - var r *http.Request - if r, err = http.NewRequest("GET", authURL.String(), nil); err != nil { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, authURL.String(), nil) + if err != nil { return "", err } - if registryAuth != "" { - logx.Info("私有镜像,无法获取是否有更新") - r.Header.Add("Authorization", fmt.Sprintf("Basic %s", registryAuth)) - } else { - logx.Info("No credentials found.") + req.Header.Set("Authorization", "Basic "+registryAuth) } - - var authResponse *http.Response - if authResponse, err = client.Do(r); err != nil { + authResponse, err := registryHTTPClient.Do(req) + if err != nil { return "", err } - - body, _ := io.ReadAll(authResponse.Body) - tokenResponse := &types.TokenResponse{} - - err = json.Unmarshal(body, tokenResponse) + defer authResponse.Body.Close() + if authResponse.StatusCode != http.StatusOK { + return "", fmt.Errorf("registry token 服务返回 %s", authResponse.Status) + } + body, err := io.ReadAll(io.LimitReader(authResponse.Body, maxRegistryBodySize+1)) if err != nil { return "", err } - - return fmt.Sprintf("Bearer %s", tokenResponse.Token), nil + if len(body) > maxRegistryBodySize { + return "", errors.New("registry token 响应超过大小限制") + } + tokenResponse := &types.TokenResponse{} + if err := json.Unmarshal(body, tokenResponse); err != nil { + return "", err + } + token := tokenResponse.Token + if token == "" { + token = tokenResponse.AccessToken + } + if token == "" { + return "", errors.New("registry token 响应中没有 token") + } + return "Bearer " + token, nil } func GetAuthURL(challenge string, imageRef ref.Named) (*url.URL, error) { - loweredChallenge := strings.ToLower(challenge) - raw := strings.TrimPrefix(loweredChallenge, "bearer") - - pairs := strings.Split(raw, ",") - values := make(map[string]string, len(pairs)) - - for _, pair := range pairs { - trimmed := strings.Trim(pair, " ") - if key, val, ok := strings.Cut(trimmed, "="); ok { - values[key] = strings.Trim(val, `"`) - } + trimmed := strings.TrimSpace(challenge) + if scheme, rest, found := strings.Cut(trimmed, " "); found && strings.EqualFold(scheme, "bearer") { + trimmed = rest } - if values["realm"] == "" || values["service"] == "" { - - return nil, fmt.Errorf("challenge header did not include all values needed to construct an auth url") + values, err := parseChallengeParameters(trimmed) + if err != nil { + return nil, err } - - authURL, _ := url.Parse(values["realm"]) - q := authURL.Query() - q.Add("service", values["service"]) - - scopeImage := ref.Path(imageRef) - - scope := fmt.Sprintf("repository:%s:pull", scopeImage) - q.Add("scope", scope) - - authURL.RawQuery = q.Encode() + realm := values["realm"] + if realm == "" { + return nil, errors.New("challenge header 缺少 realm") + } + authURL, err := url.Parse(realm) + if err != nil || authURL.Scheme != "https" || authURL.Host == "" { + return nil, errors.New("registry token realm 必须是有效的 HTTPS 地址") + } + query := authURL.Query() + if service := values["service"]; service != "" { + query.Set("service", service) + } + query.Set("scope", fmt.Sprintf("repository:%s:pull", ref.Path(imageRef))) + authURL.RawQuery = query.Encode() return authURL, nil } -func GetChallengeURL(imageRef ref.Named) url.URL { - host, _ := GetRegistryAddress(imageRef.Name()) +func parseChallengeParameters(value string) (map[string]string, error) { + parameters := make(map[string]string) + for len(strings.TrimSpace(value)) > 0 { + value = strings.TrimSpace(value) + key, rest, found := strings.Cut(value, "=") + if !found { + return nil, errors.New("challenge 参数格式错误") + } + key = strings.ToLower(strings.TrimSpace(key)) + rest = strings.TrimSpace(rest) + if key == "" || !strings.HasPrefix(rest, `"`) { + return nil, errors.New("challenge 参数格式错误") + } + rest = rest[1:] + end := -1 + escaped := false + for index, character := range rest { + if character == '\\' && !escaped { + escaped = true + continue + } + if character == '"' && !escaped { + end = index + break + } + escaped = false + } + if end < 0 { + return nil, errors.New("challenge 参数引号未闭合") + } + parameters[key] = rest[:end] + value = strings.TrimSpace(rest[end+1:]) + if value == "" { + break + } + if !strings.HasPrefix(value, ",") { + return nil, errors.New("challenge 参数缺少逗号") + } + value = value[1:] + } + return parameters, nil +} - URL := url.URL{ - Scheme: "https", - Host: host, - Path: "/v2/", +func GetChallengeURL(imageRef ref.Named) (url.URL, error) { + host, err := GetRegistryAddress(imageRef.Name()) + if err != nil { + return url.URL{}, err } - return URL + return url.URL{Scheme: "https", Host: host, Path: "/v2/"}, nil } func GetRegistryAddress(imageRef string) (string, error) { @@ -159,52 +211,9 @@ func GetRegistryAddress(imageRef string) (string, error) { if err != nil { return "", err } - address := ref.Domain(normalizedRef) - if address == DefaultRegistryDomain { - if checkHost(DefaultRegistryHost) { - address = DefaultRegistryHost - } else { - for _, host := range DefaultAcceleratorHostList { - if checkHost(host) { - address = host - break - } - } - } - if address == DefaultRegistryDomain { - address = DefaultRegistryHost - } + address = DefaultRegistryHost } return address, nil } - -func checkHost(host string) bool { - URL := "https://" + host + "/v2/" - // 创建带有超时设置的 http.Client - client := http.Client{ - Timeout: 5 * time.Second, - } - // 发送 HEAD 请求 - resp, err := client.Get(URL) - if err != nil { - logx.Errorf("Failed to connect to %s: %s", URL, err) - return false - } - defer func(Body io.ReadCloser) { - err := Body.Close() - if err != nil { - logx.Errorf("关闭body失败" + err.Error()) - } - }(resp.Body) - - // 检查 HTTP 响应状态码 - if resp.StatusCode == http.StatusOK || - resp.StatusCode == http.StatusUnauthorized { - return true - } - - logx.Errorf("Failed to connect to %s: %s", URL, resp.Status) - return false -} diff --git a/internal/module/checkupdate.go b/internal/module/checkupdate.go index d2200c35..9208d783 100644 --- a/internal/module/checkupdate.go +++ b/internal/module/checkupdate.go @@ -1,150 +1,360 @@ package module import ( - "crypto/tls" + "context" "errors" "fmt" - ref "github.com/distribution/reference" - "github.com/onlyLTY/dockerCopilot/internal/types" - "github.com/zeromicro/go-zero/core/logx" "io" - "net" "net/http" - url2 "net/url" + "net/url" "strings" + "sync" "time" + + ref "github.com/distribution/reference" + "github.com/docker/docker/client" + "github.com/onlyLTY/dockerCopilot/internal/imageref" + "github.com/onlyLTY/dockerCopilot/internal/types" + "github.com/zeromicro/go-zero/core/logx" ) -// ImageCheckList 检查更新处理后的镜像列表 type ImageCheckList struct { NeedUpdate bool } + type ImageUpdateData struct { - Data map[string]ImageCheckList + mu sync.RWMutex + checkMu sync.Mutex + data map[string]ImageCheckList } const ContentDigestHeader = "Docker-Content-Digest" +var manifestHTTPClient = secureRegistryHTTPClient(30 * time.Second) + func NewImageCheck() *ImageUpdateData { - return &ImageUpdateData{ - Data: map[string]ImageCheckList{}, + return &ImageUpdateData{data: map[string]ImageCheckList{}} +} + +func (i *ImageUpdateData) CheckUpdate(ctx context.Context, dockerClient *client.Client, imageList []types.Image) { + if !i.checkMu.TryLock() { + logx.Info("镜像更新检查仍在运行,跳过本轮重复任务") + return + } + defer i.checkMu.Unlock() + liveReferences := make(map[string]struct{}) + uniqueImages := make(map[string]types.Image) + for _, image := range expandImageReferences(imageList) { + key := imageref.CacheKey(image.Reference) + if key == "" { + continue + } + liveReferences[key] = struct{}{} + if _, exists := uniqueImages[key]; !exists { + uniqueImages[key] = image + } + } + type checkResult struct { + key string + needUpdate bool + comparable bool + } + jobs := make(chan types.Image) + results := make(chan checkResult, len(uniqueImages)) + workerCount := 4 + if len(uniqueImages) < workerCount { + workerCount = len(uniqueImages) + } + var workers sync.WaitGroup + for worker := 0; worker < workerCount; worker++ { + workers.Add(1) + go func() { + defer workers.Done() + for image := range jobs { + needUpdate, comparable := checkSingleImage(ctx, dockerClient, image) + results <- checkResult{ + key: imageref.CacheKey(image.Reference), needUpdate: needUpdate, comparable: comparable, + } + } + }() + } + for _, image := range uniqueImages { + jobs <- image + } + close(jobs) + workers.Wait() + close(results) + checked := make(map[string]ImageCheckList, len(uniqueImages)) + for result := range results { + if result.comparable { + checked[result.key] = ImageCheckList{NeedUpdate: result.needUpdate} + } + } + + i.mu.Lock() + for reference, previous := range i.data { + if _, live := liveReferences[reference]; !live { + continue + } + if _, refreshed := checked[reference]; !refreshed { + checked[reference] = previous + } } + i.data = checked + i.mu.Unlock() } -func (i *ImageUpdateData) CheckUpdate(imageList []types.Image) { + +func expandImageReferences(imageList []types.Image) []types.Image { + expanded := make([]types.Image, 0, len(imageList)) for _, image := range imageList { - if strings.Contains(image.ImageName, "0nlylty/dockercopilot") { - continue + references := image.RepoTags + if len(references) == 0 && image.Reference != "" { + references = []string{image.Reference} + } + for _, value := range references { + parsed, err := imageref.ParseTagged(value) + if err != nil { + if isDigestOnlyReference(value) { + logx.Debugf("跳过 digest 固定镜像引用 %q:未配置 tag,无法检查更新", value) + } else { + logx.Errorf("跳过无法解析的镜像引用 %q: %v", value, err) + } + continue + } + copy := image + copy.Reference = parsed.Normalized + copy.ImageName = parsed.Familiar + copy.ImageTag = parsed.Tag + expanded = append(expanded, copy) } - i.checkSingleImage(image) } + return expanded } -func (i *ImageUpdateData) checkSingleImage(image types.Image) { - token, err := GetToken(image, "") +func isDigestOnlyReference(value string) bool { + parsed, err := ref.ParseNormalizedNamed(strings.TrimSpace(value)) if err != nil { - logx.Error("获取token失败或者无需获取token,继续尝试检查" + err.Error()) + return false } - digestURL, err := BuildManifestURL(image) + _, hasDigest := parsed.(ref.Digested) + _, hasTag := parsed.(ref.NamedTagged) + return hasDigest && !hasTag +} + +func checkSingleImage(ctx context.Context, dockerClient *client.Client, image types.Image) (bool, bool) { + imageReference, err := referenceForImage(image) if err != nil { - logx.Error("获取digestURL失败" + err.Error()) - return + logx.Errorf("镜像引用无效: %v", err) + return false, false + } + image.Reference = imageReference + localDigests := repoDigestsForReference(image.RepoDigests, image.Reference) + if len(localDigests) == 0 { + logx.Errorf("镜像 %s 没有可比较的本地 RepoDigest", image.Reference) + return false, false } - remoteDigest, err := GetDigest(digestURL, token) + remoteDigest, err := getRemoteDigest(ctx, dockerClient, image) if err != nil { - logx.Error("获取digest失败" + err.Error()) - return + logx.Errorf("获取镜像 %s 的远端 digest 失败: %v", image.Reference, err) + return false, false } - if len(image.RepoDigests) == 0 { - logx.Error("未在本地获取到repoDigest" + image.ImageName + ":" + image.ImageTag) - return + needUpdate, comparable := compareRepoDigests(localDigests, remoteDigest) + if !comparable { + return false, false } - needUpdate := false - for _, localRepoDigests := range image.RepoDigests { - localDigest := strings.Split(localRepoDigests, "@")[1] - if remoteDigest != localDigest { - if remoteDigest == "" || localDigest == "" { - logx.Error("Digest为空" + image.ImageName + ":" + image.ImageTag) - continue - } - logx.Info(image.ImageName + ":" + image.ImageTag + " need update") - logx.Infof("localDigest: %s, remoteDigest: %s", localDigest, remoteDigest) - needUpdate = true - } else { - logx.Info(image.ImageName + ":" + image.ImageTag + " not need update") - needUpdate = false - } + if needUpdate { + logx.Infof("镜像 %s 有更新,本地 %v,远端 %s", image.Reference, localDigests, remoteDigest) } - i.Data[image.ID] = ImageCheckList{NeedUpdate: needUpdate} + return needUpdate, true } -func BuildManifestURL(image types.Image) (string, error) { - normalizedRef, err := ref.ParseDockerRef(image.ImageName + ":" + image.ImageTag) +func getRemoteDigest(ctx context.Context, dockerClient *client.Client, image types.Image) (string, error) { + imageReference, err := referenceForImage(image) if err != nil { return "", err } - normalizedTaggedRef, isTagged := normalizedRef.(ref.NamedTagged) - if !isTagged { - return "", errors.New("镜像无tag" + normalizedRef.String()) + credentials, credentialErr := credentialsForReference(imageReference) + if credentialErr != nil { + logx.Errorf("读取 registry 凭据失败: %v", credentialErr) + } + var daemonInspectErr error + if dockerClient != nil { + inspectCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + distribution, inspectErr := dockerClient.DistributionInspect(inspectCtx, imageReference, credentials.Encoded) + cancel() + if inspectErr == nil && distribution.Descriptor.Digest.String() != "" { + return distribution.Descriptor.Digest.String(), nil + } + daemonInspectErr = inspectErr + if daemonInspectErr == nil { + daemonInspectErr = errors.New("守护进程返回了空 digest") + } + } + + token, tokenErr := GetToken(ctx, image, credentials.Basic) + if tokenErr != nil { + return "", fallbackDigestError(imageReference, daemonInspectErr, tokenErr) + } + digestURL, err := BuildManifestURL(image) + if err != nil { + return "", fallbackDigestError(imageReference, daemonInspectErr, err) + } + digest, registryErr := GetDigest(ctx, digestURL, token) + if registryErr != nil { + return "", fallbackDigestError(imageReference, daemonInspectErr, registryErr) + } + if daemonInspectErr != nil { + logx.Debugf("通过 Docker 守护进程获取 %s digest 失败,Registry API 回退成功: %v", imageReference, daemonInspectErr) } + return digest, nil +} - host, ErrGetRegistryAddress := GetRegistryAddress(normalizedTaggedRef.Name()) - img, tag := ref.Path(normalizedTaggedRef), normalizedTaggedRef.Tag() +func fallbackDigestError(imageReference string, daemonErr, registryErr error) error { + if daemonErr == nil { + return registryErr + } + return fmt.Errorf( + "通过 Docker 守护进程获取 %s digest 失败(%v),Registry API 回退也失败: %w", + imageReference, + daemonErr, + registryErr, + ) +} - if ErrGetRegistryAddress != nil { - return "", ErrGetRegistryAddress +func referenceForImage(image types.Image) (string, error) { + value := strings.TrimSpace(image.Reference) + if value == "" && image.ImageName != "" && image.ImageTag != "" { + value = image.ImageName + ":" + image.ImageTag } + parsed, err := imageref.ParseTagged(value) + if err != nil { + return "", err + } + return parsed.Normalized, nil +} - url := url2.URL{ - Scheme: "https", - Host: host, - Path: fmt.Sprintf("/v2/%s/manifests/%s", img, tag), +func repoDigestsForReference(repoDigests []string, imageReference string) []string { + parsedTarget, err := imageref.ParseTagged(imageReference) + if err != nil { + return nil } - return url.String(), nil + matching := make([]string, 0, len(repoDigests)) + for _, value := range repoDigests { + named, err := ref.ParseNormalizedNamed(value) + if err != nil || ref.TrimNamed(named).Name() != parsedTarget.Repository { + continue + } + if _, ok := named.(ref.Digested); ok { + matching = append(matching, value) + } + } + return matching } -func GetDigest(url string, token string) (string, error) { - tr := &http.Transport{ - Proxy: http.ProxyFromEnvironment, - DialContext: (&net.Dialer{ - Timeout: 30 * time.Second, - KeepAlive: 30 * time.Second, - }).DialContext, - ForceAttemptHTTP2: true, - MaxIdleConns: 100, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, +func compareRepoDigests(repoDigests []string, remoteDigest string) (needUpdate bool, comparable bool) { + remoteDigest = strings.TrimSpace(remoteDigest) + if remoteDigest == "" { + return false, false } - client := &http.Client{Transport: tr} + hasLocalDigest := false + for _, repoDigest := range repoDigests { + _, localDigest, found := strings.Cut(repoDigest, "@") + localDigest = strings.TrimSpace(localDigest) + if !found || localDigest == "" { + continue + } + hasLocalDigest = true + if localDigest == remoteDigest { + return false, true + } + } + if !hasLocalDigest { + return false, false + } + return true, true +} - req, _ := http.NewRequest("HEAD", url, nil) +func (i *ImageUpdateData) NeedUpdate(imageReference string) bool { + i.mu.RLock() + defer i.mu.RUnlock() + result, ok := i.data[imageref.CacheKey(imageReference)] + return ok && result.NeedUpdate +} - if token != "" { - req.Header.Add("Authorization", token) +func (i *ImageUpdateData) MarkCurrent(imageReference string) { + key := imageref.CacheKey(imageReference) + if key == "" { + return } - req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.v2+json") - req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.list.v2+json") - req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.v1+json") - req.Header.Add("Accept", "application/vnd.oci.image.index.v1+json") + i.mu.Lock() + if i.data == nil { + i.data = make(map[string]ImageCheckList) + } + i.data[key] = ImageCheckList{NeedUpdate: false} + i.mu.Unlock() +} - res, err := client.Do(req) +func BuildManifestURL(image types.Image) (string, error) { + imageReference, err := referenceForImage(image) if err != nil { return "", err } - defer func(Body io.ReadCloser) { - err := Body.Close() + normalizedRef, err := ref.ParseDockerRef(imageReference) + if err != nil { + return "", err + } + normalizedTaggedRef, isTagged := normalizedRef.(ref.NamedTagged) + if !isTagged { + return "", errors.New("镜像引用没有 tag") + } + host, err := GetRegistryAddress(normalizedTaggedRef.Name()) + if err != nil { + return "", err + } + manifestURL := url.URL{ + Scheme: "https", + Host: host, + Path: fmt.Sprintf("/v2/%s/manifests/%s", ref.Path(normalizedTaggedRef), normalizedTaggedRef.Tag()), + } + return manifestURL.String(), nil +} + +func GetDigest(ctx context.Context, manifestURL, token string) (string, error) { + request := func(method string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, manifestURL, nil) if err != nil { - logx.Error("GetDigest关闭body失败" + err.Error()) + return nil, err } - }(res.Body) - - if res.StatusCode != 200 { - wwwAuthHeader := res.Header.Get("www-authenticate") - if wwwAuthHeader == "" { - wwwAuthHeader = "not present" + if token != "" { + req.Header.Set("Authorization", token) } - return "", fmt.Errorf("registry responded to head request with %q, auth: %q", res.Status, wwwAuthHeader) + req.Header.Set("Accept", strings.Join([]string{ + "application/vnd.docker.distribution.manifest.v2+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.oci.image.index.v1+json", + "application/vnd.oci.image.manifest.v1+json", + }, ", ")) + return manifestHTTPClient.Do(req) + } + response, err := request(http.MethodHead) + if err != nil { + return "", err + } + if response.StatusCode == http.StatusMethodNotAllowed { + _ = response.Body.Close() + response, err = request(http.MethodGet) + if err != nil { + return "", err + } + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4<<10)) + return "", fmt.Errorf("registry manifest 请求返回 %s", response.Status) + } + digest := strings.TrimSpace(response.Header.Get(ContentDigestHeader)) + if digest == "" { + return "", errors.New("registry 响应缺少 Docker-Content-Digest") } - return res.Header.Get(ContentDigestHeader), nil + return digest, nil } diff --git a/internal/module/checkupdate_test.go b/internal/module/checkupdate_test.go new file mode 100644 index 00000000..83959108 --- /dev/null +++ b/internal/module/checkupdate_test.go @@ -0,0 +1,183 @@ +package module + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + dockerimage "github.com/docker/docker/api/types/image" + "github.com/docker/docker/client" + "github.com/onlyLTY/dockerCopilot/internal/types" +) + +func TestFallbackDigestErrorPreservesBothFailures(t *testing.T) { + daemonErr := errors.New("daemon EOF") + registryErr := errors.New("registry timeout") + err := fallbackDigestError("example.test/app:latest", daemonErr, registryErr) + if !errors.Is(err, registryErr) { + t.Fatalf("fallback error does not wrap the Registry API failure: %v", err) + } + for _, expected := range []string{"example.test/app:latest", "daemon EOF", "registry timeout"} { + if !strings.Contains(err.Error(), expected) { + t.Fatalf("fallback error %q does not contain %q", err, expected) + } + } +} + +func TestCompareRepoDigests(t *testing.T) { + tests := []struct { + name string + local []string + remote string + needUpdate bool + comparable bool + }{ + { + name: "single digest matches", + local: []string{"postgres@sha256:current"}, + remote: "sha256:current", + needUpdate: false, + comparable: true, + }, + { + name: "single digest differs", + local: []string{"postgres@sha256:old"}, + remote: "sha256:current", + needUpdate: true, + comparable: true, + }, + { + name: "matching digest is not last", + local: []string{ + "postgres@sha256:current", + "mirror.example/postgres@sha256:mirror", + }, + remote: "sha256:current", + needUpdate: false, + comparable: true, + }, + { + name: "matching digest is last", + local: []string{ + "mirror.example/postgres@sha256:mirror", + "postgres@sha256:current", + }, + remote: "sha256:current", + needUpdate: false, + comparable: true, + }, + { + name: "malformed local digest is ignored", + local: []string{"postgres:17-alpine", "postgres@sha256:current"}, + remote: "sha256:current", + needUpdate: false, + comparable: true, + }, + { + name: "no usable local digest", + local: []string{"postgres:17-alpine"}, + remote: "sha256:current", + needUpdate: false, + comparable: false, + }, + { + name: "empty remote digest", + local: []string{"postgres@sha256:current"}, + remote: "", + needUpdate: false, + comparable: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + needUpdate, comparable := compareRepoDigests(tt.local, tt.remote) + if needUpdate != tt.needUpdate || comparable != tt.comparable { + t.Fatalf( + "compareRepoDigests() = (%v, %v), want (%v, %v)", + needUpdate, + comparable, + tt.needUpdate, + tt.comparable, + ) + } + }) + } +} + +func TestImageUpdateDataMarkCurrent(t *testing.T) { + data := NewImageCheck() + data.data["sha256:image"] = ImageCheckList{NeedUpdate: true} + + data.MarkCurrent("sha256:image") + + if data.NeedUpdate("sha256:image") { + t.Fatal("expected MarkCurrent to clear the cached update state") + } +} + +func TestExpandImageReferencesSkipsDigestOnlyReferences(t *testing.T) { + const digestReference = "ghcr.io/autunn/dockercopilot@sha256:3667bdb9f23780de257a2105755c82e1633afc01e98189e7270c3c5a9b3ee30e" + if !isDigestOnlyReference(digestReference) { + t.Fatal("expected digest-only reference to be recognized") + } + + expanded := expandImageReferences([]types.Image{ + {Reference: digestReference}, + {Reference: "postgres:17-alpine"}, + }) + if len(expanded) != 1 { + t.Fatalf("expected only the tagged reference, got %+v", expanded) + } + if expanded[0].Reference != "docker.io/library/postgres:17-alpine" { + t.Fatalf("unexpected normalized reference %q", expanded[0].Reference) + } +} + +func TestCheckSingleImageUsesDockerDaemonDigest(t *testing.T) { + const currentDigest = "sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1.47/distribution/docker.io/library/postgres:17-alpine/json" { + t.Errorf("unexpected Docker API path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "Descriptor": { + "mediaType": "application/vnd.oci.image.index.v1+json", + "digest": "` + currentDigest + `", + "size": 1024 + }, + "Platforms": [ + {"architecture": "amd64", "os": "linux"}, + {"architecture": "arm64", "os": "linux", "variant": "v8"} + ] + }`)) + })) + defer server.Close() + + dockerClient, err := client.NewClientWithOpts( + client.WithHost(server.URL), + client.WithVersion("1.47"), + client.WithHTTPClient(server.Client()), + ) + if err != nil { + t.Fatalf("failed to create Docker client: %v", err) + } + + needUpdate, comparable := checkSingleImage(context.Background(), dockerClient, types.Image{ + ImageName: "postgres", + ImageTag: "17-alpine", + Summary: dockerimage.Summary{ + RepoDigests: []string{"postgres@" + currentDigest}, + }, + }) + if needUpdate || !comparable { + t.Fatalf("checkSingleImage() = (%v, %v), want (false, true)", needUpdate, comparable) + } +} diff --git a/internal/module/registryauth.go b/internal/module/registryauth.go new file mode 100644 index 00000000..b94fb79e --- /dev/null +++ b/internal/module/registryauth.go @@ -0,0 +1,153 @@ +package module + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + ref "github.com/distribution/reference" + "github.com/docker/docker/api/types/registry" +) + +const maxDockerAuthConfigSize int64 = 1 << 20 + +type registryCredentials struct { + Basic string + Encoded string +} + +type dockerAuthFile struct { + Auths map[string]registry.AuthConfig `json:"auths"` +} + +// RegistryAuthForReference returns the Docker API encoded registry +// credentials for an image reference. Callers should pass the result to +// image.PullOptions.RegistryAuth; update checks and actual pulls must use the +// same authentication source. +func RegistryAuthForReference(imageReference string) (string, error) { + credentials, err := credentialsForReference(imageReference) + if err != nil { + return "", err + } + return credentials.Encoded, nil +} + +func credentialsForReference(imageReference string) (registryCredentials, error) { + named, err := ref.ParseNormalizedNamed(imageReference) + if err != nil { + return registryCredentials{}, err + } + domain := ref.Domain(named) + configData, err := loadDockerAuthConfig() + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return registryCredentials{}, nil + } + return registryCredentials{}, err + } + if len(configData) == 0 { + return registryCredentials{}, nil + } + var configFile dockerAuthFile + if err := json.Unmarshal(configData, &configFile); err != nil { + return registryCredentials{}, fmt.Errorf("解析 Docker registry 凭据失败: %w", err) + } + for server, authConfig := range configFile.Auths { + if !registryServerMatches(server, domain) { + continue + } + username, password, err := authUsernamePassword(authConfig) + if err != nil { + return registryCredentials{}, err + } + authConfig.Username = username + authConfig.Password = password + authConfig.ServerAddress = domain + encoded, err := registry.EncodeAuthConfig(authConfig) + if err != nil { + return registryCredentials{}, err + } + basic := authConfig.Auth + if username != "" || password != "" { + basic = base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) + } + return registryCredentials{Basic: basic, Encoded: encoded}, nil + } + return registryCredentials{}, nil +} + +func loadDockerAuthConfig() ([]byte, error) { + if raw := strings.TrimSpace(os.Getenv("DOCKER_AUTH_CONFIG")); raw != "" { + if int64(len(raw)) > maxDockerAuthConfigSize { + return nil, errors.New("DOCKER_AUTH_CONFIG 超过大小限制") + } + return []byte(raw), nil + } + configDir := strings.TrimSpace(os.Getenv("DOCKER_CONFIG")) + if configDir == "" { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + configDir = filepath.Join(home, ".docker") + } + configDir, err := filepath.Abs(configDir) + if err != nil { + return nil, err + } + configRoot, err := os.OpenRoot(configDir) + if err != nil { + return nil, err + } + defer configRoot.Close() + file, err := configRoot.Open("config.json") + if err != nil { + return nil, err + } + defer file.Close() + content, err := io.ReadAll(io.LimitReader(file, maxDockerAuthConfigSize+1)) + if err != nil { + return nil, err + } + if int64(len(content)) > maxDockerAuthConfigSize { + return nil, errors.New("docker auth config 超过大小限制") + } + return content, nil +} + +func registryServerMatches(server, domain string) bool { + server = strings.TrimSpace(strings.ToLower(server)) + server = strings.TrimPrefix(server, "https://") + server = strings.TrimPrefix(server, "http://") + server = strings.TrimSuffix(server, "/v1/") + server = strings.TrimSuffix(server, "/v2/") + server = strings.TrimSuffix(server, "/") + domain = strings.ToLower(domain) + if server == domain { + return true + } + return domain == DefaultRegistryDomain && (server == "index.docker.io" || server == "registry-1.docker.io") +} + +func authUsernamePassword(authConfig registry.AuthConfig) (string, string, error) { + if authConfig.Username != "" || authConfig.Password != "" { + return authConfig.Username, authConfig.Password, nil + } + if authConfig.Auth == "" { + return "", "", nil + } + decoded, err := base64.StdEncoding.DecodeString(authConfig.Auth) + if err != nil { + return "", "", errors.New("docker registry auth 字段格式错误") + } + username, password, ok := strings.Cut(string(decoded), ":") + if !ok { + return "", "", errors.New("docker registry auth 字段缺少密码分隔符") + } + return username, password, nil +} diff --git a/internal/module/registryauth_test.go b/internal/module/registryauth_test.go new file mode 100644 index 00000000..95f52342 --- /dev/null +++ b/internal/module/registryauth_test.go @@ -0,0 +1,56 @@ +package module + +import ( + "encoding/base64" + "strings" + "testing" + + ref "github.com/distribution/reference" + "github.com/docker/docker/api/types/image" + "github.com/onlyLTY/dockerCopilot/internal/types" +) + +func TestCredentialsForReferenceSupportsRegistryPort(t *testing.T) { + auth := base64.StdEncoding.EncodeToString([]byte("user:PasswordCase")) + t.Setenv("DOCKER_AUTH_CONFIG", `{"auths":{"registry.example:5000":{"auth":"`+auth+`"}}}`) + credentials, err := credentialsForReference("registry.example:5000/team/image:tag") + if err != nil { + t.Fatal(err) + } + if credentials.Basic != auth || credentials.Encoded == "" { + t.Fatalf("credentials were not loaded: %+v", credentials) + } +} + +func TestGetAuthURLPreservesCaseSensitiveValues(t *testing.T) { + named, err := ref.ParseNormalizedNamed("registry.example/team/image") + if err != nil { + t.Fatal(err) + } + authURL, err := GetAuthURL(`Bearer realm="https://auth.example/TokenPath?Existing=ABC",service="CaseSensitiveService"`, named) + if err != nil { + t.Fatal(err) + } + if authURL.Path != "/TokenPath" || authURL.Query().Get("Existing") != "ABC" || authURL.Query().Get("service") != "CaseSensitiveService" { + t.Fatalf("challenge values changed case: %s", authURL) + } +} + +func TestExpandImageReferencesKeepsTagsIndependent(t *testing.T) { + expanded := expandImageReferences([]types.Image{{Summary: image.Summary{ + ID: "sha256:same", RepoTags: []string{"example/repo:stable", "example/repo:latest"}, + }}}) + if len(expanded) != 2 || expanded[0].Reference == expanded[1].Reference { + t.Fatalf("tags were not expanded independently: %+v", expanded) + } +} + +func TestRepoDigestsAreFilteredByRepository(t *testing.T) { + digests := repoDigestsForReference([]string{ + "docker.io/library/postgres@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "mirror.example/postgres@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }, "postgres:17-alpine") + if len(digests) != 1 || !strings.Contains(digests[0], "sha256:aaaa") { + t.Fatalf("unexpected repository digests: %v", digests) + } +} diff --git a/internal/svc/servicecontext.go b/internal/svc/servicecontext.go index 533ebe4d..4fc5a249 100644 --- a/internal/svc/servicecontext.go +++ b/internal/svc/servicecontext.go @@ -1,12 +1,14 @@ package svc import ( + "context" "github.com/docker/docker/client" "github.com/onlyLTY/dockerCopilot/internal/config" "github.com/onlyLTY/dockerCopilot/internal/module" "github.com/zeromicro/go-zero/core/logx" "github.com/zeromicro/go-zero/rest" "sync" + "time" ) type ServiceContext struct { @@ -20,9 +22,18 @@ type ServiceContext struct { IndexCheckMiddleware rest.Middleware ProgressStore ProgressStoreType DockerClient *client.Client - mu sync.Mutex + mu sync.RWMutex + activeContainerOperations map[string]string + activeRestore bool + containerUpdateSlots chan struct{} } +const ( + TaskStatusRunning = "running" + TaskStatusCompleted = "completed" + TaskStatusFailed = "failed" +) + type TaskProgress struct { TaskID string Percentage int @@ -30,6 +41,8 @@ type TaskProgress struct { Name string DetailMsg string IsDone bool + Status string + UpdatedAt time.Time } type ProgressStoreType map[string]TaskProgress @@ -40,22 +53,132 @@ func NewServiceContext(c config.Config) *ServiceContext { logx.Errorf("Unable to create docker client: %s", err) } return &ServiceContext{ - Config: c, - HubImageInfo: module.NewImageCheck(), - ProgressStore: make(ProgressStoreType), - DockerClient: cli, + Config: c, + HubImageInfo: module.NewImageCheck(), + ProgressStore: make(ProgressStoreType), + DockerClient: cli, + activeContainerOperations: make(map[string]string), + containerUpdateSlots: make(chan struct{}, 2), } } func (ctx *ServiceContext) UpdateProgress(taskID string, progress TaskProgress) { ctx.mu.Lock() defer ctx.mu.Unlock() + if progress.Status == "" { + if progress.IsDone { + progress.Status = TaskStatusCompleted + } else { + progress.Status = TaskStatusRunning + } + } + progress.UpdatedAt = time.Now() ctx.ProgressStore[taskID] = progress + if len(ctx.ProgressStore) > 1000 { + ctx.cleanupProgressLocked(time.Hour) + } } func (ctx *ServiceContext) GetProgress(taskID string) (TaskProgress, bool) { - ctx.mu.Lock() - defer ctx.mu.Unlock() + ctx.mu.RLock() + defer ctx.mu.RUnlock() progress, ok := ctx.ProgressStore[taskID] return progress, ok } + +func (ctx *ServiceContext) CleanupProgress(retention time.Duration) int { + ctx.mu.Lock() + defer ctx.mu.Unlock() + return ctx.cleanupProgressLocked(retention) +} + +func (ctx *ServiceContext) cleanupProgressLocked(retention time.Duration) int { + cutoff := time.Now().Add(-retention) + removed := 0 + for taskID, progress := range ctx.ProgressStore { + if progress.IsDone && !progress.UpdatedAt.IsZero() && progress.UpdatedAt.Before(cutoff) { + delete(ctx.ProgressStore, taskID) + removed++ + } + } + return removed +} + +func (ctx *ServiceContext) BeginContainerUpdate(containerID string) bool { + return ctx.BeginContainerOperation(containerID, "update") +} + +func (ctx *ServiceContext) EndContainerUpdate(containerID string) { + ctx.EndContainerOperation(containerID) +} + +// BeginContainerOperation serializes all mutating operations for a container +// and prevents them from racing with a restore job. +func (ctx *ServiceContext) BeginContainerOperation(containerID, operation string) bool { + ctx.mu.Lock() + defer ctx.mu.Unlock() + if ctx.activeRestore { + return false + } + if ctx.activeContainerOperations == nil { + ctx.activeContainerOperations = make(map[string]string) + } + if _, exists := ctx.activeContainerOperations[containerID]; exists { + return false + } + ctx.activeContainerOperations[containerID] = operation + return true +} + +func (ctx *ServiceContext) EndContainerOperation(containerID string) { + ctx.mu.Lock() + defer ctx.mu.Unlock() + delete(ctx.activeContainerOperations, containerID) +} + +// AcquireContainerUpdateSlot bounds concurrent image pulls and container +// replacements. It is deliberately separate from the per-container lock so +// accepted tasks can wait without permitting another operation on that ID. +func (ctx *ServiceContext) AcquireContainerUpdateSlot(waitContext context.Context) bool { + ctx.mu.Lock() + if ctx.containerUpdateSlots == nil { + ctx.containerUpdateSlots = make(chan struct{}, 2) + } + slots := ctx.containerUpdateSlots + ctx.mu.Unlock() + select { + case slots <- struct{}{}: + return true + case <-waitContext.Done(): + return false + } +} + +func (ctx *ServiceContext) ReleaseContainerUpdateSlot() { + ctx.mu.RLock() + slots := ctx.containerUpdateSlots + ctx.mu.RUnlock() + if slots == nil { + return + } + select { + case <-slots: + default: + } +} + +func (ctx *ServiceContext) BeginRestore() bool { + ctx.mu.Lock() + defer ctx.mu.Unlock() + if ctx.activeRestore || len(ctx.activeContainerOperations) > 0 { + return false + } + ctx.activeRestore = true + return true +} + +func (ctx *ServiceContext) EndRestore() { + ctx.mu.Lock() + defer ctx.mu.Unlock() + ctx.activeRestore = false +} diff --git a/internal/svc/servicecontext_test.go b/internal/svc/servicecontext_test.go new file mode 100644 index 00000000..24d31ecf --- /dev/null +++ b/internal/svc/servicecontext_test.go @@ -0,0 +1,58 @@ +package svc + +import ( + "testing" + "time" +) + +func TestContainerUpdateLock(t *testing.T) { + ctx := &ServiceContext{} + if !ctx.BeginContainerUpdate("container") { + t.Fatal("first update lock was rejected") + } + if ctx.BeginContainerUpdate("container") { + t.Fatal("duplicate update lock was accepted") + } + ctx.EndContainerUpdate("container") + if !ctx.BeginContainerUpdate("container") { + t.Fatal("released update lock could not be acquired") + } +} + +func TestRestoreAndContainerOperationsAreMutuallyExclusive(t *testing.T) { + ctx := &ServiceContext{} + if !ctx.BeginContainerOperation("container", "rename") { + t.Fatal("first operation should acquire the lock") + } + if ctx.BeginRestore() { + t.Fatal("restore must not start while a container operation is active") + } + ctx.EndContainerOperation("container") + if !ctx.BeginRestore() { + t.Fatal("restore should start after operations finish") + } + if ctx.BeginContainerOperation("other", "start") { + t.Fatal("container operation must not start while restore is active") + } + ctx.EndRestore() +} + +func TestCleanupProgressRemovesOnlyExpiredCompletedTasks(t *testing.T) { + ctx := &ServiceContext{ProgressStore: ProgressStoreType{ + "expired": {IsDone: true, UpdatedAt: time.Now().Add(-2 * time.Hour)}, + "recent": {IsDone: true, UpdatedAt: time.Now()}, + "running": {IsDone: false, UpdatedAt: time.Now().Add(-2 * time.Hour)}, + }} + if removed := ctx.CleanupProgress(time.Hour); removed != 1 { + t.Fatalf("expected one task removed, got %d", removed) + } + if _, exists := ctx.ProgressStore["expired"]; exists { + t.Fatal("expired task remains") + } + if _, exists := ctx.ProgressStore["recent"]; !exists { + t.Fatal("recent task was removed") + } + if _, exists := ctx.ProgressStore["running"]; !exists { + t.Fatal("running task was removed") + } +} diff --git a/internal/types/imageInfo.go b/internal/types/imageInfo.go index 0788bdf5..3a3802f2 100644 --- a/internal/types/imageInfo.go +++ b/internal/types/imageInfo.go @@ -8,6 +8,7 @@ type Image struct { image.Summary ImageName string `json:"imageName"` ImageTag string `json:"imageTag"` + Reference string `json:"reference"` InUsed bool `json:"inUsed"` SizeFormat string `json:"sizeFormat"` } diff --git a/internal/types/tokenResponse.go b/internal/types/tokenResponse.go index 84afffef..58f29bb3 100644 --- a/internal/types/tokenResponse.go +++ b/internal/types/tokenResponse.go @@ -1,5 +1,6 @@ package types type TokenResponse struct { - Token string `json:"token"` + Token string `json:"token"` + AccessToken string `json:"access_token"` } diff --git a/internal/types/types.go b/internal/types/types.go index a39ed544..7ac89fdd 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -16,6 +16,7 @@ type ContainerUpdateReq struct { IdReq ImageNameAndTag string `form:"imageNameAndTag"` ContainerName string `form:"containerName"` + DelOldContainer bool `form:"delOldContainer,default=true"` } type CreateContainerReq struct { diff --git a/internal/utiles/backup2compose.go b/internal/utiles/backup2compose.go index 37f08692..0990a786 100644 --- a/internal/utiles/backup2compose.go +++ b/internal/utiles/backup2compose.go @@ -1,7 +1,12 @@ package utiles import ( - dockerTypes "github.com/docker/docker/api/types" + "fmt" + "os" + "strconv" + "strings" + + "github.com/docker/docker/api/types/container" "github.com/onlyLTY/dockerCopilot/internal/svc" backupCompose "github.com/onlyLTY/dockerCopilot/internal/utiles/backup_compose" "github.com/zeromicro/go-zero/core/logx" @@ -12,7 +17,7 @@ func Backup2Compose(ctx *svc.ServiceContext) (err error) { if err != nil { return err } - var containerJSONs []dockerTypes.ContainerJSON + containerJSONs := make([]container.InspectResponse, 0, len(containerList)) for _, v := range containerList { containerID := v.ID inspectedContainer, err := GetContainerInspect(ctx, containerID) @@ -22,10 +27,28 @@ func Backup2Compose(ctx *svc.ServiceContext) (err error) { } containerJSONs = append(containerJSONs, inspectedContainer) } - err = backupCompose.DockerConfig2ComposeYaml(containerJSONs) + includeSensitiveEnvironment := false + if raw := strings.TrimSpace(os.Getenv("COMPOSE_BACKUP_INCLUDE_SECRETS")); raw != "" { + parsed, parseErr := strconv.ParseBool(raw) + if parseErr != nil { + return fmt.Errorf("COMPOSE_BACKUP_INCLUDE_SECRETS 配置错误: %w", parseErr) + } + includeSensitiveEnvironment = parsed + } + composeYAML, err := backupCompose.DockerConfig2ComposeYamlWithOptions(containerJSONs, backupCompose.Options{ + IncludeSensitiveEnvironment: includeSensitiveEnvironment, + }) if err != nil { logx.Error("备份失败" + err.Error()) return err } + backupDir, err := ensureBackupDir() + if err != nil { + return err + } + if err := writeBackupAtomic(backupDir, newBackupFilename(".yaml"), composeYAML); err != nil { + logx.Errorf("写入 Compose 备份失败: %v", err) + return err + } return nil } diff --git a/internal/utiles/backup_compose/config2yaml.go b/internal/utiles/backup_compose/config2yaml.go index 9058bbcf..cbb528e6 100644 --- a/internal/utiles/backup_compose/config2yaml.go +++ b/internal/utiles/backup_compose/config2yaml.go @@ -1,59 +1,53 @@ package backupCompose import ( + "fmt" + "strconv" + "strings" + "time" + composeType "github.com/compose-spec/compose-go/types" - dockerTypes "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" composeNat "github.com/docker/go-connections/nat" "github.com/zeromicro/go-zero/core/logx" - "os" - "path/filepath" "sigs.k8s.io/yaml" - "strconv" - "strings" - "time" ) +type Options struct { + IncludeSensitiveEnvironment bool +} + // DockerConfig2ComposeYaml 将docker config转换为docker-compose.yaml -func DockerConfig2ComposeYaml(containerJSONs []dockerTypes.ContainerJSON) (err error) { +func DockerConfig2ComposeYaml(containerJSONs []container.InspectResponse) ([]byte, error) { + return DockerConfig2ComposeYamlWithOptions(containerJSONs, Options{}) +} + +func DockerConfig2ComposeYamlWithOptions(containerJSONs []container.InspectResponse, options Options) ([]byte, error) { var c composeYaml c.Services = make(map[string]composeType.ServiceConfig, len(containerJSONs)) + c.Networks = make(map[string]composeType.NetworkConfig) + if !options.IncludeSensitiveEnvironment { + c.Warnings = []string{"Sensitive environment variable values were omitted; provide them when deploying this Compose file."} + } for _, containerJSON := range containerJSONs { + if containerJSON.Config == nil || containerJSON.HostConfig == nil || containerJSON.NetworkSettings == nil { + return nil, fmt.Errorf("container %s has incomplete inspect data", containerJSON.ID) + } var s composeType.ServiceConfig formatBaseServiceConfig(containerJSON, &s) - formatEnvServiceConfig(containerJSON, &s) - formatNetworkServiceConfig(containerJSON, &s) + formatEnvServiceConfig(containerJSON, &s, options) + formatNetworkServiceConfig(containerJSON, &s, &c) formatVolumeServiceConfig(containerJSON, &s) c.Services[s.Name] = s } - // write to file - backupDir := os.Getenv("BACKUP_DIR") // 从环境变量中获取备份目录 - if backupDir == "" { - backupDir = "/data/backups" // 如果环境变量未设置,使用默认值 - } - _, err = os.Stat(backupDir) - if os.IsNotExist(err) { - err = os.MkdirAll(backupDir, 0755) - if err != nil { - logx.Error("Error creating backup directory:", err) - return err - } - } yamlData, yamlMarshalErr := yaml.Marshal(c) if yamlMarshalErr != nil { - logx.Errorf("Error marshalling data err is: %v", yamlMarshalErr) - } - currentDate := time.Now().Format("2006-01-02") - fileName := "backup-" + currentDate + ".yaml" - fullPath := filepath.Join(backupDir, fileName) - err = os.WriteFile(fullPath, yamlData, 0644) - if err != nil { - logx.Error("Error writing to file:", err) - return err + return nil, fmt.Errorf("marshal compose yaml: %w", yamlMarshalErr) } - return + return yamlData, nil } -func formatBaseServiceConfig(containerJSON dockerTypes.ContainerJSON, s *composeType.ServiceConfig) { +func formatBaseServiceConfig(containerJSON container.InspectResponse, s *composeType.ServiceConfig) { s.Image = containerJSON.Config.Image name, cutNameResult := strings.CutPrefix(containerJSON.Name, "/") if !cutNameResult { @@ -62,47 +56,184 @@ func formatBaseServiceConfig(containerJSON dockerTypes.ContainerJSON, s *compose s.ContainerName = name s.Name = name s.Tty = containerJSON.Config.Tty + s.StdinOpen = containerJSON.Config.OpenStdin + s.Hostname = containerJSON.Config.Hostname + s.DomainName = containerJSON.Config.Domainname + s.User = containerJSON.Config.User + s.Platform = containerJSON.Platform + s.Labels = composeType.Labels(containerJSON.Config.Labels) + s.StopSignal = containerJSON.Config.StopSignal + if containerJSON.Config.StopTimeout != nil { + duration := composeType.Duration(time.Duration(*containerJSON.Config.StopTimeout) * time.Second) + s.StopGracePeriod = &duration + } + if health := containerJSON.Config.Healthcheck; health != nil { + healthConfig := &composeType.HealthCheckConfig{ + Test: composeType.HealthCheckTest(health.Test), + Retries: uint64Pointer(health.Retries), + Disable: len(health.Test) == 1 && strings.EqualFold(health.Test[0], "NONE"), + } + healthConfig.Interval = durationPointer(health.Interval) + healthConfig.Timeout = durationPointer(health.Timeout) + healthConfig.StartPeriod = durationPointer(health.StartPeriod) + healthConfig.StartInterval = durationPointer(health.StartInterval) + s.HealthCheck = healthConfig + } if len(containerJSON.Config.Entrypoint) > 0 { s.Entrypoint = composeType.ShellCommand(containerJSON.Config.Entrypoint) } s.WorkingDir = containerJSON.Config.WorkingDir s.Restart = string(containerJSON.HostConfig.RestartPolicy.Name) s.Privileged = containerJSON.HostConfig.Privileged + s.ReadOnly = containerJSON.HostConfig.ReadonlyRootfs + s.Init = containerJSON.HostConfig.Init + s.CapAdd = append([]string(nil), containerJSON.HostConfig.CapAdd...) + s.CapDrop = append([]string(nil), containerJSON.HostConfig.CapDrop...) + s.SecurityOpt = append([]string(nil), containerJSON.HostConfig.SecurityOpt...) + s.GroupAdd = append([]string(nil), containerJSON.HostConfig.GroupAdd...) + s.DNS = append(composeType.StringList(nil), containerJSON.HostConfig.DNS...) + s.DNSOpts = append([]string(nil), containerJSON.HostConfig.DNSOptions...) + s.DNSSearch = append(composeType.StringList(nil), containerJSON.HostConfig.DNSSearch...) + s.ExtraHosts = extraHosts(containerJSON.HostConfig.ExtraHosts) + s.Sysctls = composeType.Mapping(containerJSON.HostConfig.Sysctls) + s.Tmpfs = tmpfsList(containerJSON.HostConfig.Tmpfs) + s.Ipc = string(containerJSON.HostConfig.IpcMode) + s.Pid = string(containerJSON.HostConfig.PidMode) + s.Uts = string(containerJSON.HostConfig.UTSMode) + s.UserNSMode = string(containerJSON.HostConfig.UsernsMode) + s.Runtime = containerJSON.HostConfig.Runtime + s.Isolation = string(containerJSON.HostConfig.Isolation) + s.Links = append([]string(nil), containerJSON.HostConfig.Links...) + s.ShmSize = composeType.UnitBytes(containerJSON.HostConfig.ShmSize) + s.VolumeDriver = containerJSON.HostConfig.VolumeDriver + s.VolumesFrom = append([]string(nil), containerJSON.HostConfig.VolumesFrom...) + s.CgroupParent = containerJSON.HostConfig.CgroupParent + s.Cgroup = string(containerJSON.HostConfig.Cgroup) + s.Annotations = composeType.Mapping(containerJSON.HostConfig.Annotations) + s.DeviceCgroupRules = append([]string(nil), containerJSON.HostConfig.DeviceCgroupRules...) + s.Devices = formatDevices(containerJSON.HostConfig.Devices) + s.Ulimits = formatUlimits(containerJSON.HostConfig.Ulimits) + s.CPUShares = containerJSON.HostConfig.CPUShares + s.CPUPeriod = containerJSON.HostConfig.CPUPeriod + s.CPUQuota = containerJSON.HostConfig.CPUQuota + s.CPURTPeriod = containerJSON.HostConfig.CPURealtimePeriod + s.CPURTRuntime = containerJSON.HostConfig.CPURealtimeRuntime + s.CPUSet = containerJSON.HostConfig.CpusetCpus + if containerJSON.HostConfig.NanoCPUs > 0 { + s.CPUS = float32(float64(containerJSON.HostConfig.NanoCPUs) / 1_000_000_000) + } + s.CPUCount = containerJSON.HostConfig.CPUCount + s.CPUPercent = float32(containerJSON.HostConfig.CPUPercent) + s.MemLimit = composeType.UnitBytes(containerJSON.HostConfig.Memory) + s.MemReservation = composeType.UnitBytes(containerJSON.HostConfig.MemoryReservation) + s.MemSwapLimit = composeType.UnitBytes(containerJSON.HostConfig.MemorySwap) + if containerJSON.HostConfig.MemorySwappiness != nil { + s.MemSwappiness = composeType.UnitBytes(*containerJSON.HostConfig.MemorySwappiness) + } + if containerJSON.HostConfig.OomKillDisable != nil { + s.OomKillDisable = *containerJSON.HostConfig.OomKillDisable + } + s.OomScoreAdj = int64(containerJSON.HostConfig.OomScoreAdj) + if containerJSON.HostConfig.PidsLimit != nil { + s.PidsLimit = *containerJSON.HostConfig.PidsLimit + } + if driver := containerJSON.HostConfig.LogConfig.Type; driver != "" { + s.Logging = &composeType.LoggingConfig{Driver: driver, Options: composeType.Options(containerJSON.HostConfig.LogConfig.Config)} + } if len(containerJSON.Config.Cmd) > 0 { s.Command = composeType.ShellCommand(containerJSON.Config.Cmd) } - return } -func formatEnvServiceConfig(containerJSON dockerTypes.ContainerJSON, s *composeType.ServiceConfig) { +func formatEnvServiceConfig(containerJSON container.InspectResponse, s *composeType.ServiceConfig, options Options) { s.Environment = composeType.NewMappingWithEquals(containerJSON.Config.Env) - return + if options.IncludeSensitiveEnvironment { + return + } + for key := range s.Environment { + if isSensitiveEnvironmentKey(key) { + s.Environment[key] = nil + } + } } -func formatNetworkServiceConfig(containerJSON dockerTypes.ContainerJSON, s *composeType.ServiceConfig) { - s.NetworkMode = string(containerJSON.HostConfig.NetworkMode) - for containerPort, v := range containerJSON.HostConfig.PortBindings { - var p composeType.ServicePortConfig +func formatNetworkServiceConfig(containerJSON container.InspectResponse, s *composeType.ServiceConfig, c *composeYaml) { + networkMode := string(containerJSON.HostConfig.NetworkMode) + if networkMode == "host" || networkMode == "none" || strings.HasPrefix(networkMode, "container:") || strings.HasPrefix(networkMode, "service:") { + s.NetworkMode = networkMode + } else if containerJSON.NetworkSettings != nil && len(containerJSON.NetworkSettings.Networks) > 0 { + s.Networks = make(map[string]*composeType.ServiceNetworkConfig, len(containerJSON.NetworkSettings.Networks)) + for networkName, endpoint := range containerJSON.NetworkSettings.Networks { + if endpoint == nil || networkName == "" { + continue + } + aliases := filterGeneratedAliases(endpoint.Aliases, containerJSON.Name, containerJSON.ID) + serviceNetwork := &composeType.ServiceNetworkConfig{Aliases: aliases} + if endpoint.IPAMConfig != nil { + serviceNetwork.Ipv4Address = endpoint.IPAMConfig.IPv4Address + serviceNetwork.Ipv6Address = endpoint.IPAMConfig.IPv6Address + serviceNetwork.LinkLocalIPs = append([]string(nil), endpoint.IPAMConfig.LinkLocalIPs...) + } + s.Networks[networkName] = serviceNetwork + c.Networks[networkName] = composeType.NetworkConfig{ + Name: networkName, External: composeType.External{External: true}, + } + } + } else if networkMode != "" && networkMode != "default" && networkMode != "bridge" { + s.NetworkMode = networkMode + } + for containerPort, bindings := range containerJSON.HostConfig.PortBindings { proto, port := composeNat.SplitProtoPort(string(containerPort)) - portNum, convertErr := strconv.Atoi(port) + portNum, convertErr := strconv.ParseUint(port, 10, 16) if convertErr != nil { logx.Errorf("Error converting port err is: %v", convertErr) continue } - p.Target = uint32(portNum) - p.Published = v[0].HostPort - p.Protocol = proto - s.Ports = append(s.Ports, p) + targetPort := uint32(portNum) + if len(bindings) == 0 { + s.Ports = append(s.Ports, composeType.ServicePortConfig{Target: targetPort, Protocol: proto}) + continue + } + for _, binding := range bindings { + s.Ports = append(s.Ports, composeType.ServicePortConfig{ + Target: targetPort, Published: binding.HostPort, + HostIP: binding.HostIP, Protocol: proto, + }) + } + } + if containerJSON.Config != nil { + for exposedPort := range containerJSON.Config.ExposedPorts { + if _, published := containerJSON.HostConfig.PortBindings[exposedPort]; published { + continue + } + s.Expose = append(s.Expose, string(exposedPort)) + } } } -func formatVolumeServiceConfig(containerJSON dockerTypes.ContainerJSON, s *composeType.ServiceConfig) { +func formatVolumeServiceConfig(containerJSON container.InspectResponse, s *composeType.ServiceConfig) { for _, containerVolume := range containerJSON.Mounts { var v composeType.ServiceVolumeConfig v.Type = string(containerVolume.Type) v.Source = containerVolume.Source + if string(containerVolume.Type) == "volume" && containerVolume.Name != "" { + v.Source = containerVolume.Name + } v.Target = containerVolume.Destination - v.ReadOnly = containerVolume.RW + v.ReadOnly = !containerVolume.RW + if v.Type == "bind" { + v.Bind = &composeType.ServiceVolumeBind{ + Propagation: string(containerVolume.Propagation), CreateHostPath: true, + } + for _, option := range strings.Split(containerVolume.Mode, ",") { + switch option { + case "z", "Z": + v.Bind.SELinux = option + case "consistent", "cached", "delegated": + v.Consistency = option + } + } + } s.Volumes = append(s.Volumes, v) } } diff --git a/internal/utiles/backup_compose/config2yaml_test.go b/internal/utiles/backup_compose/config2yaml_test.go new file mode 100644 index 00000000..427bb766 --- /dev/null +++ b/internal/utiles/backup_compose/config2yaml_test.go @@ -0,0 +1,96 @@ +package backupCompose + +import ( + "testing" + + composeType "github.com/compose-spec/compose-go/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/mount" + "github.com/docker/docker/api/types/network" + "github.com/docker/go-connections/nat" +) + +func TestFormatVolumePreservesReadOnlyMeaning(t *testing.T) { + containerJSON := container.InspectResponse{Mounts: []container.MountPoint{ + {Type: mount.TypeBind, Source: "/rw", Destination: "/data", RW: true}, + {Type: mount.TypeBind, Source: "/ro", Destination: "/config", RW: false}, + }} + var service composeType.ServiceConfig + formatVolumeServiceConfig(containerJSON, &service) + if len(service.Volumes) != 2 || service.Volumes[0].ReadOnly || !service.Volumes[1].ReadOnly { + t.Fatalf("volume permissions were reversed: %+v", service.Volumes) + } +} + +func TestFormatNetworkPreservesAllPortBindings(t *testing.T) { + containerJSON := container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ + HostConfig: &container.HostConfig{PortBindings: nat.PortMap{ + nat.Port("8080/tcp"): { + {HostIP: "127.0.0.1", HostPort: "18080"}, + {HostIP: "0.0.0.0", HostPort: "28080"}, + }, + nat.Port("9090/tcp"): {}, + }}, + }} + var service composeType.ServiceConfig + compose := composeYaml{Networks: make(map[string]composeType.NetworkConfig)} + formatNetworkServiceConfig(containerJSON, &service, &compose) + if len(service.Ports) != 3 { + t.Fatalf("expected all bindings plus exposed port, got %+v", service.Ports) + } + hostIPs := map[string]bool{} + for _, port := range service.Ports { + if port.Target == 8080 { + hostIPs[port.HostIP] = true + } + } + if !hostIPs["127.0.0.1"] || !hostIPs["0.0.0.0"] { + t.Fatalf("host IP was dropped: %+v", service.Ports) + } +} + +func TestSensitiveEnvironmentValuesAreOmittedByDefault(t *testing.T) { + containerJSON := container.InspectResponse{Config: &container.Config{ + Env: []string{"APP_MODE=production", "POSTGRES_PASSWORD=super-secret"}, + }} + var service composeType.ServiceConfig + formatEnvServiceConfig(containerJSON, &service, Options{}) + if service.Environment["APP_MODE"] == nil || *service.Environment["APP_MODE"] != "production" { + t.Fatalf("ordinary environment was not preserved: %+v", service.Environment) + } + if service.Environment["POSTGRES_PASSWORD"] != nil { + t.Fatal("sensitive environment value was written to plaintext compose output") + } +} + +func TestSensitiveEnvironmentCanBeExplicitlyIncluded(t *testing.T) { + containerJSON := container.InspectResponse{Config: &container.Config{Env: []string{"API_TOKEN=value"}}} + var service composeType.ServiceConfig + formatEnvServiceConfig(containerJSON, &service, Options{IncludeSensitiveEnvironment: true}) + if service.Environment["API_TOKEN"] == nil || *service.Environment["API_TOKEN"] != "value" { + t.Fatal("explicit secret inclusion was ignored") + } +} + +func TestFormatNetworkOnlyPinsExplicitIPAMAddresses(t *testing.T) { + containerJSON := container.InspectResponse{ + ContainerJSONBase: &container.ContainerJSONBase{ID: "abcdefabcdef1234", Name: "/app", HostConfig: &container.HostConfig{}}, + Config: &container.Config{}, + NetworkSettings: &container.NetworkSettings{Networks: map[string]*network.EndpointSettings{ + "dynamic": {IPAddress: "172.20.0.2", GlobalIPv6Address: "fd00::2"}, + "static": { + IPAddress: "172.21.0.2", + IPAMConfig: &network.EndpointIPAMConfig{IPv4Address: "172.21.0.20", IPv6Address: "fd01::20"}, + }, + }}, + } + var service composeType.ServiceConfig + compose := composeYaml{Networks: make(map[string]composeType.NetworkConfig)} + formatNetworkServiceConfig(containerJSON, &service, &compose) + if service.Networks["dynamic"].Ipv4Address != "" || service.Networks["dynamic"].Ipv6Address != "" { + t.Fatalf("runtime-assigned addresses were exported as static: %+v", service.Networks["dynamic"]) + } + if service.Networks["static"].Ipv4Address != "172.21.0.20" || service.Networks["static"].Ipv6Address != "fd01::20" { + t.Fatalf("explicit IPAM addresses were lost: %+v", service.Networks["static"]) + } +} diff --git a/internal/utiles/backup_compose/extended.go b/internal/utiles/backup_compose/extended.go new file mode 100644 index 00000000..f747683b --- /dev/null +++ b/internal/utiles/backup_compose/extended.go @@ -0,0 +1,105 @@ +package backupCompose + +import ( + "regexp" + "sort" + "strings" + "time" + + composeType "github.com/compose-spec/compose-go/types" + "github.com/docker/docker/api/types/container" +) + +var sensitiveEnvironmentKeyPattern = regexp.MustCompile(`(?i)(password|passwd|secret|token|api[_-]?key|private[_-]?key|credential|cookie|session|auth)`) + +func isSensitiveEnvironmentKey(key string) bool { + return sensitiveEnvironmentKeyPattern.MatchString(strings.TrimSpace(key)) +} + +func durationPointer(value time.Duration) *composeType.Duration { + if value <= 0 { + return nil + } + duration := composeType.Duration(value) + return &duration +} + +func uint64Pointer(value int) *uint64 { + if value <= 0 { + return nil + } + converted := uint64(value) + return &converted +} + +func extraHosts(values []string) composeType.HostsList { + result := make(composeType.HostsList) + for _, value := range values { + host, address, found := strings.Cut(value, "=") + if !found { + host, address, found = strings.Cut(value, ":") + } + if found && strings.TrimSpace(host) != "" && strings.TrimSpace(address) != "" { + result[strings.TrimSpace(host)] = strings.TrimSpace(address) + } + } + return result +} + +func tmpfsList(values map[string]string) composeType.StringList { + keys := make([]string, 0, len(values)) + for path := range values { + keys = append(keys, path) + } + sort.Strings(keys) + result := make(composeType.StringList, 0, len(values)) + for _, path := range keys { + if options := strings.TrimSpace(values[path]); options != "" { + result = append(result, path+":"+options) + } else { + result = append(result, path) + } + } + return result +} + +func formatDevices(devices []container.DeviceMapping) []string { + result := make([]string, 0, len(devices)) + for _, device := range devices { + value := device.PathOnHost + ":" + device.PathInContainer + if device.CgroupPermissions != "" { + value += ":" + device.CgroupPermissions + } + result = append(result, value) + } + return result +} + +func formatUlimits(ulimits []*container.Ulimit) map[string]*composeType.UlimitsConfig { + result := make(map[string]*composeType.UlimitsConfig, len(ulimits)) + for _, ulimit := range ulimits { + if ulimit == nil || ulimit.Name == "" { + continue + } + result[ulimit.Name] = &composeType.UlimitsConfig{Soft: int(ulimit.Soft), Hard: int(ulimit.Hard)} + } + return result +} + +func filterGeneratedAliases(aliases []string, rawContainerName, containerID string) []string { + containerName := strings.TrimPrefix(rawContainerName, "/") + result := make([]string, 0, len(aliases)) + seen := make(map[string]struct{}) + for _, alias := range aliases { + alias = strings.TrimSpace(alias) + if alias == "" || alias == containerName || strings.HasPrefix(containerID, alias) || strings.HasPrefix(alias, containerID) { + continue + } + if _, exists := seen[alias]; exists { + continue + } + seen[alias] = struct{}{} + result = append(result, alias) + } + return result +} diff --git a/internal/utiles/backup_compose/types.go b/internal/utiles/backup_compose/types.go index 4688a31d..0888646f 100644 --- a/internal/utiles/backup_compose/types.go +++ b/internal/utiles/backup_compose/types.go @@ -6,4 +6,6 @@ import ( type composeYaml struct { Services map[string]composeType.ServiceConfig `yaml:"services" json:"services"` + Networks map[string]composeType.NetworkConfig `yaml:"networks,omitempty" json:"networks,omitempty"` + Warnings []string `yaml:"x-docker-copilot-warnings,omitempty" json:"x-docker-copilot-warnings,omitempty"` } diff --git a/internal/utiles/backupcontainer.go b/internal/utiles/backupcontainer.go index 45ba6e92..672476ce 100644 --- a/internal/utiles/backupcontainer.go +++ b/internal/utiles/backupcontainer.go @@ -3,69 +3,78 @@ package utiles import ( "context" "encoding/json" + "errors" + dockerBackend "github.com/docker/docker/api/types/backend" - "github.com/docker/docker/api/types/network" "github.com/onlyLTY/dockerCopilot/internal/svc" "github.com/zeromicro/go-zero/core/logx" - "os" - "path/filepath" - "time" ) +type containerBackupEntry struct { + dockerBackend.ContainerCreateConfig + WasRunning *bool `json:"WasRunning,omitempty"` + WasPaused *bool `json:"WasPaused,omitempty"` +} + func BackupContainer(ctx *svc.ServiceContext) error { + if ctx.DockerClient == nil { + return errors.New("docker 客户端不可用") + } containerList, err := GetContainerList(ctx) if err != nil { return err } - var backupList []dockerBackend.ContainerCreateConfig - for i, v := range containerList { - containerID := containerList[i].ID - ctx.DockerClient.NegotiateAPIVersion(context.TODO()) - inspectedContainer, err := ctx.DockerClient.ContainerInspect(context.TODO(), containerID) + backupList := make([]containerBackupEntry, 0, len(containerList)) + for _, listedContainer := range containerList { + inspectContext, cancel := context.WithTimeout(context.Background(), dockerAPITimeout) + inspectedContainer, err := ctx.DockerClient.ContainerInspect(inspectContext, listedContainer.ID) + cancel() if err != nil { - logx.Error("获取容器信息失败" + err.Error()) return err } - var containerName string - if len(v.Names) > 0 { - containerName = v.Names[0][1:] - } else { - containerName = "get container name error" - logx.Error("get container name error" + v.ID) + if inspectedContainer.Config == nil || inspectedContainer.HostConfig == nil || inspectedContainer.NetworkSettings == nil { + return errors.New("docker 返回的容器配置不完整") } - inspectedContainer.Config.Hostname = "" - inspectedContainer.Image = inspectedContainer.Config.Image - config := inspectedContainer.Config - hostConfig := inspectedContainer.HostConfig - networkingConfig := &network.NetworkingConfig{ - EndpointsConfig: inspectedContainer.NetworkSettings.Networks, + containerName := listedContainer.ID + if len(listedContainer.Names) > 0 { + containerName = listedContainer.Names[0] + if len(containerName) > 0 && containerName[0] == '/' { + containerName = containerName[1:] + } } - createConfig := dockerBackend.ContainerCreateConfig{Config: config, HostConfig: hostConfig, NetworkingConfig: networkingConfig, Name: containerName} - backupList = append(backupList, createConfig) + clearGeneratedHostname(inspectedContainer.Config, inspectedContainer.ID) + wasPaused := inspectedContainer.State != nil && inspectedContainer.State.Paused + wasRunning := inspectedContainer.State != nil && (inspectedContainer.State.Running || wasPaused) + backupList = append(backupList, containerBackupEntry{ + ContainerCreateConfig: dockerBackend.ContainerCreateConfig{ + Config: inspectedContainer.Config, + HostConfig: inspectedContainer.HostConfig, + NetworkingConfig: networkingConfigForRecreate(inspectedContainer), + Name: containerName, + }, + WasRunning: &wasRunning, + WasPaused: &wasPaused, + }) } jsonData, err := json.MarshalIndent(backupList, "", " ") if err != nil { - logx.Error("Error marshalling data:", err) return err } - backupDir := os.Getenv("BACKUP_DIR") // 从环境变量中获取备份目录 - if backupDir == "" { - backupDir = "/data/backups" // 如果环境变量未设置,使用默认值 + secret, err := backupEncryptionSecret(ctx.Config.Auth.AccessSecret) + if err != nil { + return err } - _, err = os.Stat(backupDir) - if os.IsNotExist(err) { - err = os.MkdirAll(backupDir, 0755) - if err != nil { - logx.Error("Error creating backup directory:", err) - return err - } + encrypted, err := encryptBackup(jsonData, secret) + if err != nil { + return err } - currentDate := time.Now().Format("2006-01-02") - fileName := "backup-" + currentDate + ".json" - fullPath := filepath.Join(backupDir, fileName) - err = os.WriteFile(fullPath, jsonData, 0644) + backupDir, err := ensureBackupDir() if err != nil { - logx.Error("Error writing to file:", err) + return err + } + filename := newBackupFilename(".json") + if err := writeBackupAtomic(backupDir, filename, encrypted); err != nil { + logx.Errorf("写入备份失败: %v", err) return err } return nil diff --git a/internal/utiles/backuppath.go b/internal/utiles/backuppath.go index 6c2151db..86569746 100644 --- a/internal/utiles/backuppath.go +++ b/internal/utiles/backuppath.go @@ -18,6 +18,10 @@ func BackupDir() string { return backupDir } +func backupDirectoryPath() (string, error) { + return filepath.Abs(filepath.Clean(BackupDir())) +} + func ResolveBackupPath(filename string, allowedExts ...string) (string, error) { name := strings.TrimSpace(filename) if name == "" { @@ -44,7 +48,10 @@ func ResolveBackupPath(filename string, allowedExts ...string) (string, error) { } } - base := filepath.Clean(BackupDir()) + base, err := backupDirectoryPath() + if err != nil { + return "", fmt.Errorf("备份目录无效: %w", err) + } fullPath := filepath.Clean(filepath.Join(base, name)) basePrefix := base + string(os.PathSeparator) if fullPath != base && !strings.HasPrefix(fullPath, basePrefix) { @@ -53,3 +60,17 @@ func ResolveBackupPath(filename string, allowedExts ...string) (string, error) { return fullPath, nil } + +// ReadBackupDownload returns an authenticated download payload while reusing +// the same filename, symlink and size checks as restore operations. +func ReadBackupDownload(filename string) ([]byte, string, error) { + fullPath, err := ResolveBackupPath(filename, ".json", ".yaml", ".yml") + if err != nil { + return nil, "", err + } + content, err := readBackupFile(fullPath) + if err != nil { + return nil, "", err + } + return content, filepath.Base(fullPath), nil +} diff --git a/internal/utiles/backuppath_test.go b/internal/utiles/backuppath_test.go new file mode 100644 index 00000000..65eb4f48 --- /dev/null +++ b/internal/utiles/backuppath_test.go @@ -0,0 +1,26 @@ +package utiles + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadBackupDownloadValidatesPathAndContent(t *testing.T) { + directory := t.TempDir() + t.Setenv("BACKUP_DIR", directory) + filename := "backup-test.yaml" + if err := os.WriteFile(filepath.Join(directory, filename), []byte("services: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + content, returnedName, err := ReadBackupDownload(filename) + if err != nil { + t.Fatal(err) + } + if returnedName != filename || string(content) != "services: {}\n" { + t.Fatalf("unexpected download: name=%q content=%q", returnedName, content) + } + if _, _, err := ReadBackupDownload("../outside.yaml"); err == nil { + t.Fatal("path traversal download was accepted") + } +} diff --git a/internal/utiles/backupsecurity.go b/internal/utiles/backupsecurity.go new file mode 100644 index 00000000..bbda867d --- /dev/null +++ b/internal/utiles/backupsecurity.go @@ -0,0 +1,154 @@ +package utiles + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +const backupFormat = "docker-copilot-backup" + +var backupAssociatedData = []byte("docker-copilot-backup-v1") + +type encryptedBackupEnvelope struct { + Format string `json:"format"` + Version int `json:"version"` + Algorithm string `json:"algorithm"` + Nonce string `json:"nonce"` + Ciphertext string `json:"ciphertext"` +} + +func backupEncryptionSecret(accessSecret string) (string, error) { + secret := os.Getenv("BACKUP_ENCRYPTION_KEY") + if secret == "" { + secret = accessSecret + } + return secret, nil +} + +func encryptBackup(plaintext []byte, secret string) ([]byte, error) { + key := sha256.Sum256([]byte("docker-copilot:" + secret)) + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + ciphertext := gcm.Seal(nil, nonce, plaintext, backupAssociatedData) + return json.MarshalIndent(encryptedBackupEnvelope{ + Format: backupFormat, Version: 1, Algorithm: "AES-256-GCM", + Nonce: base64.StdEncoding.EncodeToString(nonce), + Ciphertext: base64.StdEncoding.EncodeToString(ciphertext), + }, "", " ") +} + +func decryptBackup(content []byte, secret string) ([]byte, error) { + trimmed := strings.TrimSpace(string(content)) + if strings.HasPrefix(trimmed, "[") { + // Backwards compatibility for backups created before encryption support. + return content, nil + } + var envelope encryptedBackupEnvelope + if err := json.Unmarshal(content, &envelope); err != nil { + return nil, errors.New("备份文件格式错误") + } + if envelope.Format != backupFormat || envelope.Version != 1 || envelope.Algorithm != "AES-256-GCM" { + return nil, errors.New("不支持的备份文件格式") + } + nonce, err := base64.StdEncoding.DecodeString(envelope.Nonce) + if err != nil { + return nil, errors.New("备份 nonce 格式错误") + } + ciphertext, err := base64.StdEncoding.DecodeString(envelope.Ciphertext) + if err != nil { + return nil, errors.New("备份密文格式错误") + } + key := sha256.Sum256([]byte("docker-copilot:" + secret)) + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + if len(nonce) != gcm.NonceSize() { + return nil, errors.New("备份 nonce 长度错误") + } + plaintext, err := gcm.Open(nil, nonce, ciphertext, backupAssociatedData) + if err != nil { + return nil, errors.New("备份解密失败,请检查 BACKUP_ENCRYPTION_KEY") + } + return plaintext, nil +} + +func ensureBackupDir() (string, error) { + dir, err := backupDirectoryPath() + if err != nil { + return "", err + } + // #nosec G301,G703 -- this is the explicitly configured backup root; 0700 is intentionally owner-only. + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + // #nosec G302,G703 -- directories require execute permission; 0700 is the restrictive usable mode. + if err := os.Chmod(dir, 0o700); err != nil { + return "", err + } + return dir, nil +} + +func newBackupFilename(extension string) string { + return fmt.Sprintf("backup-%s%s", time.Now().Format("2006-01-02T15-04-05.000000000"), extension) +} + +func writeBackupAtomic(dir, filename string, content []byte) (retErr error) { + if filename != filepath.Base(filename) || filename == "." { + return errors.New("非法备份文件名") + } + root, err := os.OpenRoot(dir) + if err != nil { + return err + } + defer root.Close() + temporary, err := os.CreateTemp(dir, ".backup-*") + if err != nil { + return err + } + temporaryName := filepath.Base(temporary.Name()) + defer func() { + _ = temporary.Close() + if retErr != nil { + _ = root.Remove(temporaryName) + } + }() + if err := temporary.Chmod(0o600); err != nil { + return err + } + if _, err := temporary.Write(content); err != nil { + return err + } + if err := temporary.Sync(); err != nil { + return err + } + if err := temporary.Close(); err != nil { + return err + } + return root.Rename(temporaryName, filename) +} diff --git a/internal/utiles/backupsecurity_test.go b/internal/utiles/backupsecurity_test.go new file mode 100644 index 00000000..d398ee15 --- /dev/null +++ b/internal/utiles/backupsecurity_test.go @@ -0,0 +1,61 @@ +package utiles + +import ( + "bytes" + "testing" +) + +func TestBackupEncryptionRoundTrip(t *testing.T) { + plaintext := []byte(`[{"Config":{"Env":["PASSWORD=secret"]}}]`) + encrypted, err := encryptBackup(plaintext, "correct horse battery staple") + if err != nil { + t.Fatal(err) + } + if bytes.Contains(encrypted, []byte("PASSWORD")) || bytes.Contains(encrypted, []byte("secret")) { + t.Fatalf("encrypted backup contains plaintext secret: %s", encrypted) + } + decrypted, err := decryptBackup(encrypted, "correct horse battery staple") + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Fatalf("round trip mismatch: %s", decrypted) + } + if _, err := decryptBackup(encrypted, "wrong secret"); err == nil { + t.Fatal("backup decrypted with wrong key") + } +} + +func TestDecryptBackupSupportsLegacyPlaintext(t *testing.T) { + legacy := []byte(" [ {\"Name\":\"legacy\"} ]") + decrypted, err := decryptBackup(legacy, "unused secret") + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(decrypted, legacy) { + t.Fatalf("legacy backup changed: %s", decrypted) + } +} + +func TestBackupEncryptionUsesUserChosenShortSecret(t *testing.T) { + t.Setenv("BACKUP_ENCRYPTION_KEY", "") + secret, err := backupEncryptionSecret("123456") + if err != nil { + t.Fatalf("user-chosen secret was rejected: %v", err) + } + if secret != "123456" { + t.Fatalf("secret was modified: %q", secret) + } + plaintext := []byte(`[{"Name":"short-secret"}]`) + encrypted, err := encryptBackup(plaintext, secret) + if err != nil { + t.Fatal(err) + } + decrypted, err := decryptBackup(encrypted, secret) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(decrypted, plaintext) { + t.Fatalf("round trip mismatch: %s", decrypted) + } +} diff --git a/internal/utiles/backupslist.go b/internal/utiles/backupslist.go index 8e3502a1..d55de89f 100644 --- a/internal/utiles/backupslist.go +++ b/internal/utiles/backupslist.go @@ -4,25 +4,37 @@ import ( "github.com/onlyLTY/dockerCopilot/internal/svc" "os" "path/filepath" + "sort" + "strings" ) func BackupList(ctx *svc.ServiceContext) ([]string, error) { + _ = ctx var backupList []string - dir := os.Getenv("BACKUP_DIR") // 从环境变量中获取备份目录 - if dir == "" { - dir = "/data/backups" // 如果环境变量未设置,使用默认值 + dir, err := ensureBackupDir() + if err != nil { + return nil, err + } + root, err := os.OpenRoot(dir) + if err != nil { + return nil, err } - entries, err := os.ReadDir(dir) + defer root.Close() + directory, err := root.Open(".") + if err != nil { + return nil, err + } + defer directory.Close() + entries, err := directory.ReadDir(-1) if err != nil { return nil, err } for _, entry := range entries { - if !entry.IsDir() && filepath.Ext(entry.Name()) == ".json" { - backupList = append(backupList, entry.Name()) - } else if !entry.IsDir() && filepath.Ext(entry.Name()) == ".yaml" { + extension := strings.ToLower(filepath.Ext(entry.Name())) + if entry.Type().IsRegular() && (extension == ".json" || extension == ".yaml") { backupList = append(backupList, entry.Name()) } } - + sort.Sort(sort.Reverse(sort.StringSlice(backupList))) return backupList, nil } diff --git a/internal/utiles/containerInspect.go b/internal/utiles/containerInspect.go index 48d4ce29..4a1f3927 100644 --- a/internal/utiles/containerInspect.go +++ b/internal/utiles/containerInspect.go @@ -1,15 +1,19 @@ package utiles import ( - "context" - "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" "github.com/onlyLTY/dockerCopilot/internal/svc" ) -func GetContainerInspect(ctx *svc.ServiceContext, id string) (types.ContainerJSON, error) { - inspectedContainer, err := ctx.DockerClient.ContainerInspect(context.TODO(), id) +func GetContainerInspect(ctx *svc.ServiceContext, id string) (container.InspectResponse, error) { + operationContext, cancel, err := dockerContext(ctx) if err != nil { - return types.ContainerJSON{}, err + return container.InspectResponse{}, err + } + defer cancel() + inspectedContainer, err := ctx.DockerClient.ContainerInspect(operationContext, id) + if err != nil { + return container.InspectResponse{}, err } return inspectedContainer, nil } diff --git a/internal/utiles/containerconfig.go b/internal/utiles/containerconfig.go new file mode 100644 index 00000000..f846d4f1 --- /dev/null +++ b/internal/utiles/containerconfig.go @@ -0,0 +1,19 @@ +package utiles + +import ( + "strings" + + "github.com/docker/docker/api/types/container" +) + +const generatedHostnameLength = 12 + +func clearGeneratedHostname(config *container.Config, containerID string) { + if config == nil { + return + } + containerID = strings.TrimPrefix(strings.TrimSpace(containerID), "sha256:") + if len(containerID) >= generatedHostnameLength && config.Hostname == containerID[:generatedHostnameLength] { + config.Hostname = "" + } +} diff --git a/internal/utiles/containerconfig_test.go b/internal/utiles/containerconfig_test.go new file mode 100644 index 00000000..2cdd2a9b --- /dev/null +++ b/internal/utiles/containerconfig_test.go @@ -0,0 +1,23 @@ +package utiles + +import ( + "testing" + + "github.com/docker/docker/api/types/container" +) + +func TestClearGeneratedHostnamePreservesCustomValue(t *testing.T) { + config := &container.Config{Hostname: "database.internal"} + clearGeneratedHostname(config, "0123456789abcdef") + if config.Hostname != "database.internal" { + t.Fatalf("custom hostname was removed: %q", config.Hostname) + } +} + +func TestClearGeneratedHostnameRemovesContainerIDPrefix(t *testing.T) { + config := &container.Config{Hostname: "0123456789ab"} + clearGeneratedHostname(config, "0123456789abcdef") + if config.Hostname != "" { + t.Fatalf("generated hostname was retained: %q", config.Hostname) + } +} diff --git a/internal/utiles/dockercontext.go b/internal/utiles/dockercontext.go new file mode 100644 index 00000000..51e9b4cc --- /dev/null +++ b/internal/utiles/dockercontext.go @@ -0,0 +1,19 @@ +package utiles + +import ( + "context" + "errors" + "time" + + "github.com/onlyLTY/dockerCopilot/internal/svc" +) + +const dockerAPITimeout = 30 * time.Second + +func dockerContext(serviceContext *svc.ServiceContext) (context.Context, context.CancelFunc, error) { + if serviceContext == nil || serviceContext.DockerClient == nil { + return nil, nil, errors.New("docker 客户端不可用") + } + ctx, cancel := context.WithTimeout(context.Background(), dockerAPITimeout) + return ctx, cancel, nil +} diff --git a/internal/utiles/getRemoteVersion.go b/internal/utiles/getRemoteVersion.go index e7413ef6..2cd56f87 100644 --- a/internal/utiles/getRemoteVersion.go +++ b/internal/utiles/getRemoteVersion.go @@ -1,63 +1,134 @@ package utiles import ( + "context" + "errors" + "fmt" "io" "net/http" + "net/url" "os" + "regexp" "strings" + "time" "github.com/onlyLTY/dockerCopilot/internal/config" - "github.com/zeromicro/go-zero/core/logx" ) -func GetRemoteVersion() (remoteVersion string, err error) { - githubProxy := os.Getenv("githubProxy") - if githubProxy != "" { - githubProxy = strings.TrimRight(githubProxy, "/") + "/" +var updateValuePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) +var updateRepositoryPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`) + +const defaultUpdateRepository = "autunn/dockerCopilot" + +func GetRemoteVersion(ctx context.Context) (string, error) { + channel, versionFile, err := updateChannel() + if err != nil { + return "", err } - versionURL := githubProxy + "https://raw.githubusercontent.com/onlyLTY/dockerCopilot/UGREEN/version" - remoteVersion, err = fetchVersionFromURL(versionURL) + repository, err := updateRepository() if err != nil { - return "0.0.0", err + return "", err + } + baseURL := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s", repository, channel, versionFile) + versionURL, err := githubURL(baseURL) + if err != nil { + return "", err } + client := newHTTPSClient(15 * time.Second) + return fetchVersionFromURL(ctx, client, versionURL) +} - localVersion := config.Version - if strings.Contains(localVersion, "FNOS") { - logx.Infof("飞牛版本,无需在线更新") - return localVersion, nil +func updateRepository() (string, error) { + repository := strings.TrimSpace(os.Getenv("UPDATE_REPOSITORY")) + if repository == "" { + repository = defaultUpdateRepository } - if localVersion == remoteVersion { - logx.Info("版本一致:", localVersion) - return remoteVersion, nil - } else { - logx.Infof("版本不一致! 本地: %s, 远程: %s\n", localVersion, remoteVersion) - return remoteVersion, nil + if !updateRepositoryPattern.MatchString(repository) { + return "", errors.New("UPDATE_REPOSITORY 必须使用 owner/repository 格式") } - + return repository, nil } -func fetchVersionFromURL(url string) (string, error) { - client := &http.Client{ - Transport: &http.Transport{ - Proxy: http.ProxyFromEnvironment, +func newHTTPSClient(timeout time.Duration) *http.Client { + return &http.Client{ + Timeout: timeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if req.URL.Scheme != "https" { + return errors.New("拒绝重定向到非 HTTPS 地址") + } + if len(via) >= 10 { + return errors.New("HTTP 重定向次数过多") + } + return nil }, } +} + +func currentVersion() string { + return strings.TrimSpace(config.Version) +} + +func updateChannel() (channel, versionFile string, err error) { + channel = strings.TrimSpace(os.Getenv("UPDATE_CHANNEL")) + if channel == "" { + switch { + case strings.Contains(strings.ToUpper(currentVersion()), "UGREEN"): + channel = "UGREEN" + case strings.Contains(strings.ToUpper(currentVersion()), "FNOS"): + channel = "FNOS" + default: + channel = "latest" + } + } + if !updateValuePattern.MatchString(channel) { + return "", "", errors.New("UPDATE_CHANNEL 格式错误") + } + switch strings.ToUpper(channel) { + case "UGREEN": + versionFile = "ugreen_version" + case "FNOS": + versionFile = "fn_version" + default: + versionFile = "version" + } + return channel, versionFile, nil +} + +func githubURL(target string) (string, error) { + proxy := strings.TrimSpace(os.Getenv("githubProxy")) + if proxy != "" { + target = strings.TrimRight(proxy, "/") + "/" + target + } + parsed, err := url.Parse(target) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return "", errors.New("githubProxy 必须生成有效的 HTTPS 地址") + } + return parsed.String(), nil +} - resp, err := client.Get(url) +func fetchVersionFromURL(ctx context.Context, client *http.Client, rawURL string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return "", err } - defer func(Body io.ReadCloser) { - err := Body.Close() - if err != nil { - logx.Error("关闭Body失败:", err) - } - }(resp.Body) - - versionData, err := io.ReadAll(resp.Body) + resp, err := client.Do(req) if err != nil { return "", err } - - return strings.TrimSpace(string(versionData)), nil + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("版本服务器返回 %s", resp.Status) + } + versionData, err := io.ReadAll(io.LimitReader(resp.Body, 1025)) + if err != nil { + return "", err + } + if len(versionData) > 1024 { + return "", errors.New("版本响应超过大小限制") + } + version := strings.TrimSpace(string(versionData)) + if !updateValuePattern.MatchString(version) { + return "", errors.New("远程版本格式错误") + } + return version, nil } diff --git a/internal/utiles/getcontainerlist.go b/internal/utiles/getcontainerlist.go index cfc7344e..e8984c0a 100644 --- a/internal/utiles/getcontainerlist.go +++ b/internal/utiles/getcontainerlist.go @@ -2,15 +2,25 @@ package utiles import ( "context" + "strings" + "time" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" + "github.com/onlyLTY/dockerCopilot/internal/imageref" "github.com/onlyLTY/dockerCopilot/internal/svc" MyType "github.com/onlyLTY/dockerCopilot/internal/types" "github.com/zeromicro/go-zero/core/logx" ) func GetContainerList(ctx *svc.ServiceContext) ([]MyType.Container, error) { + operationContext, cancel, err := dockerContext(ctx) + if err != nil { + return nil, err + } + defer cancel() // 获取所有容器(包括停止的容器) - dockerContainerList, err := ctx.DockerClient.ContainerList(context.Background(), container.ListOptions{ + dockerContainerList, err := ctx.DockerClient.ContainerList(operationContext, container.ListOptions{ All: true, // 设置为true来获取所有容器 }) if err != nil { @@ -28,12 +38,57 @@ func GetContainerList(ctx *svc.ServiceContext) ([]MyType.Container, error) { } func CheckImageUpdate(ctx *svc.ServiceContext, containerListData []MyType.Container) []MyType.Container { + resolvedImageIDs := currentImageIDsByReference(ctx) for i, v := range containerListData { - if _, ok := ctx.HubImageInfo.Data[v.ImageID]; ok { - if ctx.HubImageInfo.Data[v.ImageID].NeedUpdate { - containerListData[i].Update = true - } + if IsSelfContainerID(v.ID) { + containerListData[i].Update = false + continue } + remoteUpdate := ctx.HubImageInfo != nil && ctx.HubImageInfo.NeedUpdate(v.Image) + key := imageref.CacheKey(v.Image) + containerListData[i].Update = containerNeedsImageUpdate(remoteUpdate, resolvedImageIDs[key], v.ImageID) } return containerListData } + +// currentImageIDsByReference resolves every local tag with one Docker call. +// This keeps the frequently polled container-list endpoint from issuing one +// ImageInspect request per distinct image. +func currentImageIDsByReference(ctx *svc.ServiceContext) map[string]string { + resolved := make(map[string]string) + if ctx == nil || ctx.DockerClient == nil { + return resolved + } + listContext, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + images, err := ctx.DockerClient.ImageList(listContext, image.ListOptions{}) + if err != nil { + logx.Debugf("无法列出镜像以解析当前 ImageID: %v", err) + return resolved + } + return imageIDsByReference(images) +} + +func imageIDsByReference(images []image.Summary) map[string]string { + resolved := make(map[string]string) + for _, localImage := range images { + for _, tag := range localImage.RepoTags { + if key := imageref.CacheKey(tag); key != "" { + resolved[key] = localImage.ID + } + } + } + return resolved +} + +func containerNeedsImageUpdate(remoteUpdate bool, resolvedImageID, containerImageID string) bool { + if remoteUpdate { + return true + } + resolvedImageID = strings.TrimPrefix(strings.TrimSpace(resolvedImageID), "sha256:") + containerImageID = strings.TrimPrefix(strings.TrimSpace(containerImageID), "sha256:") + if resolvedImageID == "" || containerImageID == "" { + return false + } + return resolvedImageID != containerImageID +} diff --git a/internal/utiles/getcontainerlist_test.go b/internal/utiles/getcontainerlist_test.go new file mode 100644 index 00000000..833f5de3 --- /dev/null +++ b/internal/utiles/getcontainerlist_test.go @@ -0,0 +1,35 @@ +package utiles + +import ( + "testing" + + "github.com/docker/docker/api/types/image" +) + +func TestContainerNeedsImageUpdateUsesRunningImageID(t *testing.T) { + if !containerNeedsImageUpdate(true, "sha256:new", "sha256:old") { + t.Fatal("remote digest change must be reported") + } + if !containerNeedsImageUpdate(false, "sha256:new", "sha256:old") { + t.Fatal("container still running the old image must be reported") + } + if containerNeedsImageUpdate(false, "sha256:new", "sha256:new") { + t.Fatal("container running the tag-resolved image must be current") + } + if containerNeedsImageUpdate(false, "", "sha256:old") { + t.Fatal("an unresolved tag must not create a false update") + } +} + +func TestImageIDsByReferenceNormalizesAllTags(t *testing.T) { + resolved := imageIDsByReference([]image.Summary{ + {ID: "sha256:postgres", RepoTags: []string{"postgres:17-alpine", "docker.io/library/postgres:latest"}}, + {ID: "sha256:private", RepoTags: []string{"registry.example:5000/team/app:v1"}}, + }) + if resolved["docker.io/library/postgres:17-alpine"] != "sha256:postgres" { + t.Fatalf("Docker Hub tag was not normalized: %+v", resolved) + } + if resolved["registry.example:5000/team/app:v1"] != "sha256:private" { + t.Fatalf("private registry tag was not normalized: %+v", resolved) + } +} diff --git a/internal/utiles/image.go b/internal/utiles/image.go index b0b37a69..7ea37e39 100644 --- a/internal/utiles/image.go +++ b/internal/utiles/image.go @@ -1,20 +1,24 @@ package utiles import ( - "context" "fmt" + ref "github.com/distribution/reference" "github.com/docker/docker/api/types/image" + "github.com/onlyLTY/dockerCopilot/internal/imageref" "github.com/onlyLTY/dockerCopilot/internal/svc" MyType "github.com/onlyLTY/dockerCopilot/internal/types" - "log" - "strings" ) func GetImagesList(ctx *svc.ServiceContext) ([]MyType.Image, error) { var imagesList []MyType.Image - dockerImages, err := ctx.DockerClient.ImageList(context.Background(), image.ListOptions{}) + operationContext, cancel, err := dockerContext(ctx) if err != nil { - log.Fatalf("Unable to fetch docker images: %s", err) + return nil, err + } + defer cancel() + dockerImages, err := ctx.DockerClient.ImageList(operationContext, image.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("获取 Docker 镜像列表失败: %w", err) } for _, img := range dockerImages { @@ -38,10 +42,22 @@ func GetImagesList(ctx *svc.ServiceContext) ([]MyType.Image, error) { func splitImageNameAndTag(imagesList []MyType.Image) []MyType.Image { for i, imageInfo := range imagesList { if len(imageInfo.RepoTags) != 0 { - imagesList[i].ImageName = strings.Split(imageInfo.RepoTags[0], ":")[0] - imagesList[i].ImageTag = strings.Split(imageInfo.RepoTags[0], ":")[1] + parsed, err := imageref.ParseTagged(imageInfo.RepoTags[0]) + if err != nil { + imagesList[i].ImageName = imageInfo.RepoTags[0] + imagesList[i].ImageTag = "None" + continue + } + imagesList[i].ImageName = parsed.Familiar + imagesList[i].ImageTag = parsed.Tag + imagesList[i].Reference = parsed.Normalized } else if len(imageInfo.RepoDigests) != 0 { - imagesList[i].ImageName = strings.Split(imageInfo.RepoDigests[0], "@")[0] + imagesList[i].Reference = imageInfo.RepoDigests[0] + if named, err := ref.ParseNormalizedNamed(imageInfo.RepoDigests[0]); err == nil { + imagesList[i].ImageName = ref.FamiliarName(ref.TrimNamed(named)) + } else { + imagesList[i].ImageName = imageInfo.RepoDigests[0] + } imagesList[i].ImageTag = "None" } else { imagesList[i].ImageName = "None" diff --git a/internal/utiles/removeimage.go b/internal/utiles/removeimage.go index 60c6af4a..8c034757 100644 --- a/internal/utiles/removeimage.go +++ b/internal/utiles/removeimage.go @@ -1,13 +1,17 @@ package utiles import ( - "context" "github.com/docker/docker/api/types/image" "github.com/onlyLTY/dockerCopilot/internal/svc" ) func RemoveImage(ctx *svc.ServiceContext, imageID string, force bool) error { - _, err := ctx.DockerClient.ImageRemove(context.Background(), imageID, image.RemoveOptions{Force: force}) + operationContext, cancel, err := dockerContext(ctx) + if err != nil { + return err + } + defer cancel() + _, err = ctx.DockerClient.ImageRemove(operationContext, imageID, image.RemoveOptions{Force: force}) if err != nil { return err } diff --git a/internal/utiles/renamecontainer.go b/internal/utiles/renamecontainer.go index 849ab4a7..e73c31c5 100644 --- a/internal/utiles/renamecontainer.go +++ b/internal/utiles/renamecontainer.go @@ -1,12 +1,16 @@ package utiles import ( - "context" "github.com/onlyLTY/dockerCopilot/internal/svc" ) func RenameContainer(ctx *svc.ServiceContext, id string, newName string) error { - err := ctx.DockerClient.ContainerRename(context.TODO(), id, newName) + operationContext, cancel, err := dockerContext(ctx) + if err != nil { + return err + } + defer cancel() + err = ctx.DockerClient.ContainerRename(operationContext, id, newName) if err != nil { return err } diff --git a/internal/utiles/restartcontainer.go b/internal/utiles/restartcontainer.go index 639401f2..37e3c92a 100644 --- a/internal/utiles/restartcontainer.go +++ b/internal/utiles/restartcontainer.go @@ -1,19 +1,23 @@ package utiles import ( - "context" "github.com/docker/docker/api/types/container" "github.com/onlyLTY/dockerCopilot/internal/svc" ) func RestartContainer(ctx *svc.ServiceContext, id string) error { + operationContext, cancel, err := dockerContext(ctx) + if err != nil { + return err + } + defer cancel() timeout := 10 signal := "SIGINT" stopOptions := container.StopOptions{ Signal: signal, Timeout: &timeout, } - err := ctx.DockerClient.ContainerRestart(context.Background(), id, stopOptions) + err = ctx.DockerClient.ContainerRestart(operationContext, id, stopOptions) if err != nil { return err } diff --git a/internal/utiles/restorecontainer.go b/internal/utiles/restorecontainer.go index f955239a..68ed9bd2 100644 --- a/internal/utiles/restorecontainer.go +++ b/internal/utiles/restorecontainer.go @@ -3,94 +3,217 @@ package utiles import ( "context" "encoding/json" - dockerBackend "github.com/docker/docker/api/types/backend" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/network" + "github.com/onlyLTY/dockerCopilot/internal/module" "github.com/onlyLTY/dockerCopilot/internal/svc" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/zeromicro/go-zero/core/logx" - "os" - "strconv" - "strings" ) -func RestoreContainer(ctx *svc.ServiceContext, filename string, taskID string) error { - var backupList []string - oldProgress := svc.TaskProgress{ - TaskID: taskID, - Percentage: 0, - Message: "", - Name: "", - DetailMsg: "", - IsDone: false, - } - oldProgress.Name = "恢复容器" +const maxBackupFileSize int64 = 128 << 20 + +const ( + containerRestoreTimeout = 24 * time.Hour + containerRestoreItemTimeout = 30 * time.Minute +) + +type restoreClient interface { + ImagePull(context.Context, string, image.PullOptions) (io.ReadCloser, error) + ContainerCreate(context.Context, *container.Config, *container.HostConfig, *network.NetworkingConfig, *ocispec.Platform, string) (container.CreateResponse, error) + ContainerStart(context.Context, string, container.StartOptions) error + ContainerPause(context.Context, string) error + ContainerRemove(context.Context, string, container.RemoveOptions) error +} + +func RestoreContainer(serviceContext *svc.ServiceContext, filename, taskID string) error { + if serviceContext.DockerClient == nil { + err := errors.New("docker 客户端不可用") + setRestoreProgress(serviceContext, taskID, 0, "恢复失败", err.Error(), true, svc.TaskStatusFailed) + return err + } + ctx, cancel := context.WithTimeout(context.Background(), containerRestoreTimeout) + defer cancel() + return restoreContainer(ctx, serviceContext, serviceContext.DockerClient, filename, taskID) +} + +func restoreContainer(ctx context.Context, serviceContext *svc.ServiceContext, dockerClient restoreClient, filename, taskID string) error { + setRestoreProgress(serviceContext, taskID, 0, "正在读取备份", "正在读取备份", false, svc.TaskStatusRunning) fullPath, err := ResolveBackupPath(filename, ".json") if err != nil { - logx.Errorf("Failed to resolve backup path: %v", err) - oldProgress.Message = "非法文件名" - oldProgress.DetailMsg = err.Error() - oldProgress.IsDone = true - ctx.UpdateProgress(taskID, oldProgress) + setRestoreProgress(serviceContext, taskID, 0, "非法文件名", err.Error(), true, svc.TaskStatusFailed) + return err + } + content, err := readBackupFile(fullPath) + if err != nil { + setRestoreProgress(serviceContext, taskID, 0, "读取备份失败", err.Error(), true, svc.TaskStatusFailed) return err } - content, err := os.ReadFile(fullPath) + secret, err := backupEncryptionSecret(serviceContext.Config.Auth.AccessSecret) if err != nil { - logx.Errorf("Failed to read file: %v", err) - oldProgress.Percentage = 0 - oldProgress.Message = "读取文件失败或者未找到文件。请确认文件名仅由大小写字母、数字和短横线组成" - oldProgress.DetailMsg = err.Error() - oldProgress.IsDone = true - ctx.UpdateProgress(taskID, oldProgress) + setRestoreProgress(serviceContext, taskID, 0, "读取备份失败", err.Error(), true, svc.TaskStatusFailed) return err } - var configList []dockerBackend.ContainerCreateConfig - err = json.Unmarshal(content, &configList) + plaintext, err := decryptBackup(content, secret) if err != nil { - logx.Errorf("Failed to parse json: %v", err) - oldProgress.Percentage = 0 - oldProgress.Message = "解析文件失败" - oldProgress.DetailMsg = err.Error() - oldProgress.IsDone = true - ctx.UpdateProgress(taskID, oldProgress) + setRestoreProgress(serviceContext, taskID, 0, "解密备份失败", err.Error(), true, svc.TaskStatusFailed) return err } - for i, containerInfo := range configList { - info := "正在恢复第" + strconv.Itoa(i+1) + "个容器" - oldProgress.Percentage = int(float64(i) / float64(len(configList)) * 100) - oldProgress.Message = info - oldProgress.DetailMsg = info - ctx.UpdateProgress(taskID, oldProgress) - ctx.DockerClient.NegotiateAPIVersion(context.TODO()) - if err != nil { - backupList = append(backupList, "出现错误"+err.Error()) - logx.Errorf("Failed to inspect container: %v", err) - return err + var configList []containerBackupEntry + if err := json.Unmarshal(plaintext, &configList); err != nil { + setRestoreProgress(serviceContext, taskID, 0, "解析备份失败", err.Error(), true, svc.TaskStatusFailed) + return err + } + if len(configList) > 500 { + err := errors.New("单个备份最多恢复 500 个容器") + setRestoreProgress(serviceContext, taskID, 0, "解析备份失败", err.Error(), true, svc.TaskStatusFailed) + return err + } + + results := make([]string, 0, len(configList)) + failureCount := 0 + for index, containerInfo := range configList { + percentage := 5 + if len(configList) > 0 { + percentage = 5 + int(float64(index)/float64(len(configList))*90) } - reader, err := ctx.DockerClient.ImagePull(context.TODO(), containerInfo.Config.Image, image.PullOptions{}) - if err != nil { - backupList = append(backupList, containerInfo.Config.Image+"拉取镜像出现错误"+err.Error()) - logx.Errorf("Failed to pull image: %v", err) - continue + label := containerInfo.Name + if label == "" { + label = fmt.Sprintf("第 %d 个容器", index+1) } - err = decodePullResp(reader, ctx, taskID) - if err != nil { - backupList = append(backupList, containerInfo.Config.Image+"拉取镜像出现错误"+err.Error()) - logx.Errorf("Failed to pull image: %v", err) + setRestoreProgress(serviceContext, taskID, percentage, "正在恢复 "+label, "正在拉取镜像", false, svc.TaskStatusRunning) + itemContext, cancelItem := context.WithTimeout(ctx, containerRestoreItemTimeout) + restoreErr := restoreSingleContainer(itemContext, serviceContext, dockerClient, containerInfo, taskID) + cancelItem() + if restoreErr != nil { + failureCount++ + results = append(results, label+":"+restoreErr.Error()) continue } - _, err = ctx.DockerClient.ContainerCreate(context.TODO(), containerInfo.Config, containerInfo.HostConfig, containerInfo.NetworkingConfig, nil, containerInfo.Name) - if err != nil { - logx.Errorf("Failed to create container: %v", err) - info = "正在恢复第" + strconv.Itoa(i+1) + "个容器" - backupList = append(backupList, containerInfo.Name+"恢复失败"+err.Error()) - continue - } else { - backupList = append(backupList, containerInfo.Name+"恢复成功") + results = append(results, label+":恢复成功") + } + + detail := strings.Join(results, "\n") + if failureCount > 0 { + err := fmt.Errorf("%d/%d 个容器恢复失败", failureCount, len(configList)) + setRestoreProgress(serviceContext, taskID, 100, "恢复部分失败", detail, true, svc.TaskStatusFailed) + return err + } + setRestoreProgress(serviceContext, taskID, 100, "恢复完成", detail, true, svc.TaskStatusCompleted) + return nil +} + +func restoreSingleContainer(ctx context.Context, serviceContext *svc.ServiceContext, dockerClient restoreClient, containerInfo containerBackupEntry, taskID string) error { + if containerInfo.Config == nil || containerInfo.HostConfig == nil || containerInfo.Config.Image == "" { + return errors.New("备份配置不完整") + } + registryAuth, err := module.RegistryAuthForReference(containerInfo.Config.Image) + if err != nil { + return fmt.Errorf("读取镜像仓库凭据失败:%w", err) + } + reader, err := dockerClient.ImagePull(ctx, containerInfo.Config.Image, image.PullOptions{RegistryAuth: registryAuth}) + if err != nil { + return fmt.Errorf("拉取镜像失败:%w", err) + } + decodeErr := decodePullResp(reader, serviceContext, taskID) + closeErr := reader.Close() + if decodeErr != nil || closeErr != nil { + return fmt.Errorf("拉取镜像失败:%w", errors.Join(decodeErr, closeErr)) + } + networkingConfig := cleanSavedNetworkingConfig(containerInfo.NetworkingConfig, containerInfo.Config, containerInfo.Name) + created, err := dockerClient.ContainerCreate(ctx, containerInfo.Config, containerInfo.HostConfig, networkingConfig, containerInfo.Platform, containerInfo.Name) + if err != nil { + return fmt.Errorf("创建失败:%w", err) + } + if strings.TrimSpace(created.ID) == "" { + return errors.New("创建失败:Docker 未返回容器 ID") + } + if containerInfo.WasRunning == nil || !*containerInfo.WasRunning { + return nil + } + if err := dockerClient.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil { + removeErr := runContainerRecovery(func(recoveryCtx context.Context) error { + return dockerClient.ContainerRemove(recoveryCtx, created.ID, container.RemoveOptions{Force: true}) + }) + if removeErr != nil { + logx.Errorf("清理启动失败的容器 %s 失败: %v", created.ID, removeErr) + } + return fmt.Errorf("启动失败:%w", errors.Join(err, removeErr)) + } + if containerInfo.WasPaused != nil && *containerInfo.WasPaused { + if err := dockerClient.ContainerPause(ctx, created.ID); err != nil { + removeErr := runContainerRecovery(func(recoveryCtx context.Context) error { + return dockerClient.ContainerRemove(recoveryCtx, created.ID, container.RemoveOptions{Force: true}) + }) + return fmt.Errorf("恢复暂停状态失败:%w", errors.Join(err, removeErr)) } } - oldProgress.Percentage = 100 - oldProgress.DetailMsg = strings.Join(backupList, ",\n") - oldProgress.Message = "恢复完成" - oldProgress.IsDone = true - ctx.UpdateProgress(taskID, oldProgress) return nil } + +func cleanSavedNetworkingConfig(saved *network.NetworkingConfig, config *container.Config, name string) *network.NetworkingConfig { + if saved == nil { + return nil + } + inspected := container.InspectResponse{ + ContainerJSONBase: &container.ContainerJSONBase{Name: "/" + strings.TrimPrefix(name, "/")}, + Config: config, + NetworkSettings: &container.NetworkSettings{Networks: saved.EndpointsConfig}, + } + return networkingConfigForRecreate(inspected) +} + +func readBackupFile(path string) ([]byte, error) { + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return nil, err + } + defer root.Close() + name := filepath.Base(path) + info, err := root.Lstat(name) + if err != nil { + return nil, err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, errors.New("备份必须是普通文件") + } + if info.Size() > maxBackupFileSize { + return nil, errors.New("备份文件超过大小限制") + } + file, err := root.Open(name) + if err != nil { + return nil, err + } + defer file.Close() + openedInfo, err := file.Stat() + if err != nil { + return nil, err + } + if !openedInfo.Mode().IsRegular() || openedInfo.Size() > maxBackupFileSize { + return nil, errors.New("备份文件无效或超过大小限制") + } + content, err := io.ReadAll(io.LimitReader(file, maxBackupFileSize+1)) + if err != nil { + return nil, err + } + if int64(len(content)) > maxBackupFileSize { + return nil, errors.New("备份文件超过大小限制") + } + return content, nil +} + +func setRestoreProgress(serviceContext *svc.ServiceContext, taskID string, percentage int, message, detail string, done bool, status string) { + serviceContext.UpdateProgress(taskID, svc.TaskProgress{ + TaskID: taskID, Name: "恢复容器", Percentage: percentage, + Message: message, DetailMsg: detail, IsDone: done, Status: status, + }) +} diff --git a/internal/utiles/restorecontainer_test.go b/internal/utiles/restorecontainer_test.go new file mode 100644 index 00000000..9cf228b0 --- /dev/null +++ b/internal/utiles/restorecontainer_test.go @@ -0,0 +1,188 @@ +package utiles + +import ( + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + dockerBackend "github.com/docker/docker/api/types/backend" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/network" + "github.com/onlyLTY/dockerCopilot/internal/config" + "github.com/onlyLTY/dockerCopilot/internal/svc" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +type fakeRestoreClient struct { + createErr error + startErr error + cancelOnStart context.CancelFunc + removeContextErr error + started []string + paused []string + removed []string + pullOptions image.PullOptions + createdNetwork *network.NetworkingConfig +} + +func (f *fakeRestoreClient) ImagePull(_ context.Context, _ string, options image.PullOptions) (io.ReadCloser, error) { + f.pullOptions = options + return io.NopCloser(strings.NewReader("{}\n")), nil +} + +func TestRestoreContainerUsesRegistryAuthentication(t *testing.T) { + t.Setenv("DOCKER_AUTH_CONFIG", `{"auths":{"registry.example":{"auth":"dXNlcjpwYXNz"}}}`) + filename, serviceContext := writeRestoreTestBackupWithImage(t, false, "registry.example/team/app:latest") + client := &fakeRestoreClient{} + if err := restoreContainer(context.Background(), serviceContext, client, filename, "restore-task"); err != nil { + t.Fatal(err) + } + if client.pullOptions.RegistryAuth == "" { + t.Fatal("private registry credentials were not passed to restore ImagePull") + } +} + +func (f *fakeRestoreClient) ContainerCreate(_ context.Context, _ *container.Config, _ *container.HostConfig, networking *network.NetworkingConfig, _ *ocispec.Platform, _ string) (container.CreateResponse, error) { + f.createdNetwork = networking + if f.createErr != nil { + return container.CreateResponse{}, f.createErr + } + return container.CreateResponse{ID: "restored"}, nil +} + +func (f *fakeRestoreClient) ContainerStart(_ context.Context, id string, _ container.StartOptions) error { + f.started = append(f.started, id) + if f.cancelOnStart != nil { + f.cancelOnStart() + } + return f.startErr +} + +func (f *fakeRestoreClient) ContainerPause(_ context.Context, id string) error { + f.paused = append(f.paused, id) + return nil +} + +func (f *fakeRestoreClient) ContainerRemove(ctx context.Context, id string, _ container.RemoveOptions) error { + f.removed = append(f.removed, id) + f.removeContextErr = ctx.Err() + return nil +} + +func TestRestoreContainerReportsPartialFailure(t *testing.T) { + filename, serviceContext := writeRestoreTestBackup(t, true) + client := &fakeRestoreClient{createErr: errors.New("name conflict")} + err := restoreContainer(context.Background(), serviceContext, client, filename, "restore-task") + if err == nil { + t.Fatal("expected restore failure") + } + progress, _ := serviceContext.GetProgress("restore-task") + if progress.Status != svc.TaskStatusFailed || !progress.IsDone || !strings.Contains(progress.DetailMsg, "name conflict") { + t.Fatalf("partial failure was not reported: %+v", progress) + } +} + +func TestRestoreContainerRestoresRunningState(t *testing.T) { + filename, serviceContext := writeRestoreTestBackup(t, true) + client := &fakeRestoreClient{} + if err := restoreContainer(context.Background(), serviceContext, client, filename, "restore-task"); err != nil { + t.Fatal(err) + } + if len(client.started) != 1 || client.started[0] != "restored" { + t.Fatalf("running container was not restarted: %v", client.started) + } + progress, _ := serviceContext.GetProgress("restore-task") + if progress.Status != svc.TaskStatusCompleted || !progress.IsDone { + t.Fatalf("restore did not complete: %+v", progress) + } +} + +func TestRestoreContainerRestoresPausedState(t *testing.T) { + filename, serviceContext := writeRestoreTestBackupWithState(t, true, true, "repo:tag") + client := &fakeRestoreClient{} + if err := restoreContainer(context.Background(), serviceContext, client, filename, "restore-task"); err != nil { + t.Fatal(err) + } + if len(client.started) != 1 || len(client.paused) != 1 || client.paused[0] != "restored" { + t.Fatalf("paused state was not restored: started=%v paused=%v", client.started, client.paused) + } +} + +func TestCleanSavedNetworkingConfigDropsRuntimeFields(t *testing.T) { + saved := &network.NetworkingConfig{EndpointsConfig: map[string]*network.EndpointSettings{ + "project_default": { + Aliases: []string{"app", "custom"}, NetworkID: "old-network", EndpointID: "old-endpoint", + IPAddress: "172.20.0.2", IPAMConfig: &network.EndpointIPAMConfig{IPv4Address: "172.20.0.10"}, + }, + }} + clean := cleanSavedNetworkingConfig(saved, &container.Config{}, "app").EndpointsConfig["project_default"] + if clean.NetworkID != "" || clean.EndpointID != "" || clean.IPAddress != "" { + t.Fatalf("runtime fields were retained: %+v", clean) + } + if clean.IPAMConfig == nil || clean.IPAMConfig.IPv4Address != "172.20.0.10" || len(clean.Aliases) != 1 || clean.Aliases[0] != "custom" { + t.Fatalf("configured network fields were lost: %+v", clean) + } +} + +func TestRestoreContainerUsesFreshContextForCleanup(t *testing.T) { + filename, serviceContext := writeRestoreTestBackup(t, true) + primaryContext, cancel := context.WithCancel(context.Background()) + client := &fakeRestoreClient{startErr: errors.New("start failed"), cancelOnStart: cancel} + if err := restoreContainer(primaryContext, serviceContext, client, filename, "restore-task"); err == nil { + t.Fatal("expected restore failure") + } + if len(client.removed) != 1 || client.removed[0] != "restored" { + t.Fatalf("failed container was not removed: %v", client.removed) + } + if client.removeContextErr != nil { + t.Fatalf("cleanup reused cancelled context: %v", client.removeContextErr) + } +} + +func writeRestoreTestBackup(t *testing.T, wasRunning bool) (string, *svc.ServiceContext) { + return writeRestoreTestBackupWithImage(t, wasRunning, "repo:tag") +} + +func writeRestoreTestBackupWithImage(t *testing.T, wasRunning bool, imageReference string) (string, *svc.ServiceContext) { + return writeRestoreTestBackupWithState(t, wasRunning, false, imageReference) +} + +func writeRestoreTestBackupWithState(t *testing.T, wasRunning, wasPaused bool, imageReference string) (string, *svc.ServiceContext) { + t.Helper() + const accessSecret = "restore-test-secret-that-is-long-enough" + dir := t.TempDir() + t.Setenv("BACKUP_DIR", dir) + entry := containerBackupEntry{ + ContainerCreateConfig: dockerBackend.ContainerCreateConfig{ + Name: "app", Config: &container.Config{Image: imageReference}, + HostConfig: &container.HostConfig{}, NetworkingConfig: &network.NetworkingConfig{}, + }, + WasRunning: &wasRunning, + WasPaused: &wasPaused, + } + plaintext, err := json.Marshal([]containerBackupEntry{entry}) + if err != nil { + t.Fatal(err) + } + encrypted, err := encryptBackup(plaintext, accessSecret) + if err != nil { + t.Fatal(err) + } + filename := "backup-test.json" + if err := os.WriteFile(filepath.Join(dir, filename), encrypted, 0o600); err != nil { + t.Fatal(err) + } + return filename, &svc.ServiceContext{ + Config: config.Config{Auth: struct { + AccessSecret string + AccessExpire int64 + }{AccessSecret: accessSecret}}, + ProgressStore: make(svc.ProgressStoreType), + } +} diff --git a/internal/utiles/selfcontainer.go b/internal/utiles/selfcontainer.go new file mode 100644 index 00000000..59cc028e --- /dev/null +++ b/internal/utiles/selfcontainer.go @@ -0,0 +1,70 @@ +package utiles + +import ( + "os" + "regexp" + "strings" + "sync" +) + +var containerIDPattern = regexp.MustCompile(`(?i)(?:^|[^0-9a-f])([0-9a-f]{12,64})(?:[^0-9a-f]|$)`) +var selfContainerCandidatesOnce sync.Once +var cachedSelfContainerCandidates []string + +// IsSelfContainerID reports whether id identifies the container in which +// dockerCopilot is running. Updating that container through its own process +// would stop the process before it can create the replacement. +func IsSelfContainerID(id string) bool { + selfContainerCandidatesOnce.Do(func() { + cachedSelfContainerCandidates = selfContainerIDCandidates() + }) + return isSelfContainerID(id, cachedSelfContainerCandidates) +} + +func isSelfContainerID(id string, candidates []string) bool { + id = normalizeContainerID(id) + if len(id) < 12 { + return false + } + for _, candidate := range candidates { + candidate = normalizeContainerID(candidate) + if len(candidate) < 12 { + continue + } + if strings.HasPrefix(id, candidate) || strings.HasPrefix(candidate, id) { + return true + } + } + return false +} + +func selfContainerIDCandidates() []string { + candidates := make([]string, 0, 6) + if configured := strings.TrimSpace(os.Getenv("DOCKER_COPILOT_CONTAINER_ID")); configured != "" { + candidates = append(candidates, configured) + } + if hostname, err := os.Hostname(); err == nil { + candidates = append(candidates, hostname) + } + for _, path := range []string{"/proc/self/cgroup", "/proc/self/mountinfo"} { + content, err := os.ReadFile(path) + if err != nil { + continue + } + for _, match := range containerIDPattern.FindAllStringSubmatch(string(content), -1) { + if len(match) == 2 { + candidates = append(candidates, match[1]) + } + } + } + return candidates +} + +func normalizeContainerID(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + value = strings.TrimPrefix(value, "sha256:") + if match := containerIDPattern.FindStringSubmatch(value); len(match) == 2 { + return strings.ToLower(match[1]) + } + return value +} diff --git a/internal/utiles/selfcontainer_test.go b/internal/utiles/selfcontainer_test.go new file mode 100644 index 00000000..167f2409 --- /dev/null +++ b/internal/utiles/selfcontainer_test.go @@ -0,0 +1,22 @@ +package utiles + +import "testing" + +func TestIsSelfContainerIDMatchesShortAndLongIDs(t *testing.T) { + full := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + for _, id := range []string{full, full[:12], "sha256:" + full} { + if !isSelfContainerID(id, []string{full}) { + t.Fatalf("expected %q to match the running container", id) + } + } + if isSelfContainerID("abcdefabcdef", []string{full}) { + t.Fatal("unrelated container matched the running container") + } +} + +func TestNormalizeContainerIDExtractsSystemdScope(t *testing.T) { + id := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + if got := normalizeContainerID("docker-" + id + ".scope"); got != id { + t.Fatalf("unexpected normalized id %q", got) + } +} diff --git a/internal/utiles/startcontainer.go b/internal/utiles/startcontainer.go index fce4433e..ddc2d1b7 100644 --- a/internal/utiles/startcontainer.go +++ b/internal/utiles/startcontainer.go @@ -1,14 +1,18 @@ package utiles import ( - "context" "github.com/docker/docker/api/types/container" "github.com/onlyLTY/dockerCopilot/internal/svc" ) func StartContainer(ctx *svc.ServiceContext, id string) error { + operationContext, cancel, err := dockerContext(ctx) + if err != nil { + return err + } + defer cancel() startOptions := container.StartOptions{} - err := ctx.DockerClient.ContainerStart(context.Background(), id, startOptions) + err = ctx.DockerClient.ContainerStart(operationContext, id, startOptions) if err != nil { return err } diff --git a/internal/utiles/stopcontainer.go b/internal/utiles/stopcontainer.go index 86ca600b..99d378ca 100644 --- a/internal/utiles/stopcontainer.go +++ b/internal/utiles/stopcontainer.go @@ -1,19 +1,23 @@ package utiles import ( - "context" "github.com/docker/docker/api/types/container" "github.com/onlyLTY/dockerCopilot/internal/svc" ) func StopContainer(ctx *svc.ServiceContext, id string) error { + operationContext, cancel, err := dockerContext(ctx) + if err != nil { + return err + } + defer cancel() timeout := 10 signal := "SIGINT" stopOptions := container.StopOptions{ Signal: signal, Timeout: &timeout, } - err := ctx.DockerClient.ContainerStop(context.Background(), id, stopOptions) + err = ctx.DockerClient.ContainerStop(operationContext, id, stopOptions) if err != nil { return err } diff --git a/internal/utiles/updatecontainer.go b/internal/utiles/updatecontainer.go index 6ddba209..beb57fd3 100644 --- a/internal/utiles/updatecontainer.go +++ b/internal/utiles/updatecontainer.go @@ -3,225 +3,317 @@ package utiles import ( "context" "encoding/json" + "errors" "fmt" + "io" + "strings" + "time" + "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/network" dockerMsgType "github.com/docker/docker/pkg/jsonmessage" + "github.com/onlyLTY/dockerCopilot/internal/module" "github.com/onlyLTY/dockerCopilot/internal/svc" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/zeromicro/go-zero/core/logx" - "io" - "time" ) -func UpdateContainer(serviceContext *svc.ServiceContext, id string, name string, imageNameAndTag string, delOldContainer bool, taskID string) error { - ctx := context.Background() - serviceContext.UpdateProgress(taskID, svc.TaskProgress{ - TaskID: taskID, - Percentage: 0, - Name: name, - Message: "正在连接Docker", - DetailMsg: "正在连接Docker", - IsDone: false, - }) - var oldTaskProgress, result = serviceContext.GetProgress(taskID) - if !result { - oldTaskProgress = svc.TaskProgress{ - Percentage: 0, - Name: "", - Message: "", - DetailMsg: "", - IsDone: false, - } - } - timeout := 10 - signal := "SIGINT" - - serviceContext.UpdateProgress(taskID, oldTaskProgress) - oldTaskProgress.Message = "正在拉取新镜像" - oldTaskProgress.Percentage = 10 - oldTaskProgress.DetailMsg = "正在拉取新镜像" - serviceContext.UpdateProgress(taskID, oldTaskProgress) - serviceContext.DockerClient.NegotiateAPIVersion(ctx) - reader, err := serviceContext.DockerClient.ImagePull(ctx, imageNameAndTag, image.PullOptions{}) - if err != nil { - oldTaskProgress.Message = "拉取镜像失败" - oldTaskProgress.DetailMsg = err.Error() - oldTaskProgress.IsDone = true - serviceContext.UpdateProgress(taskID, oldTaskProgress) - logx.Errorf("Failed to pull image: %s", err) +const ( + containerUpdateTimeout = 30 * time.Minute + containerRecoveryTimeout = 2 * time.Minute +) + +type containerUpdateClient interface { + ImagePull(context.Context, string, image.PullOptions) (io.ReadCloser, error) + ContainerInspect(context.Context, string) (container.InspectResponse, error) + ContainerStop(context.Context, string, container.StopOptions) error + ContainerPause(context.Context, string) error + ContainerUnpause(context.Context, string) error + ContainerRename(context.Context, string, string) error + ContainerCreate(context.Context, *container.Config, *container.HostConfig, *network.NetworkingConfig, *ocispec.Platform, string) (container.CreateResponse, error) + ContainerStart(context.Context, string, container.StartOptions) error + ContainerRemove(context.Context, string, container.RemoveOptions) error +} + +func UpdateContainer(serviceContext *svc.ServiceContext, id, name, imageNameAndTag string, delOldContainer bool, taskID string) error { + if serviceContext.DockerClient == nil { + err := errors.New("docker 客户端不可用") + setContainerTaskFailure(serviceContext, taskID, name, "连接 Docker 失败", err) return err } - err = decodePullResp(reader, serviceContext, taskID) - if err != nil { - oldTaskProgress.Message = "拉取镜像失败" - oldTaskProgress.DetailMsg = err.Error() - oldTaskProgress.IsDone = true - serviceContext.UpdateProgress(taskID, oldTaskProgress) - logx.Errorf("Failed to pull image: %s", err) - return err + ctx, cancel := context.WithTimeout(context.Background(), containerUpdateTimeout) + defer cancel() + return updateContainer(ctx, serviceContext, serviceContext.DockerClient, id, name, imageNameAndTag, delOldContainer, taskID) +} + +func updateContainer(ctx context.Context, serviceContext *svc.ServiceContext, dockerClient containerUpdateClient, id, name, imageNameAndTag string, delOldContainer bool, taskID string) error { + progress := svc.TaskProgress{ + TaskID: taskID, Name: name, Percentage: 0, + Message: "正在连接 Docker", DetailMsg: "正在连接 Docker", + Status: svc.TaskStatusRunning, } - oldTaskProgress, result = serviceContext.GetProgress(taskID) - if !result { - oldTaskProgress = svc.TaskProgress{ - Percentage: 0, - Name: "", - Message: "", - DetailMsg: "", - IsDone: false, - } + update := func(percentage int, message, detail string) { + progress.Percentage = percentage + progress.Message = message + progress.DetailMsg = detail + serviceContext.UpdateProgress(taskID, progress) + } + fail := func(message string, err error) error { + progress.Message = message + progress.DetailMsg = err.Error() + progress.IsDone = true + progress.Status = svc.TaskStatusFailed + serviceContext.UpdateProgress(taskID, progress) + return err } - oldTaskProgress.Message = "拉取镜像成功" - oldTaskProgress.DetailMsg = "拉取镜像成功" + serviceContext.UpdateProgress(taskID, progress) - oldTaskProgress.Percentage = 30 - oldTaskProgress.Message = "正在停止容器" - oldTaskProgress.DetailMsg = "正在停止容器" - serviceContext.UpdateProgress(taskID, oldTaskProgress) - stopOptions := container.StopOptions{ - Signal: signal, - Timeout: &timeout, + update(10, "正在拉取新镜像", "正在拉取新镜像") + registryAuth, err := module.RegistryAuthForReference(imageNameAndTag) + if err != nil { + return fail("读取镜像仓库凭据失败", err) } - err = serviceContext.DockerClient.ContainerStop(context.Background(), id, stopOptions) + reader, err := dockerClient.ImagePull(ctx, imageNameAndTag, image.PullOptions{RegistryAuth: registryAuth}) if err != nil { - oldTaskProgress.Message = "停止容器失败" - oldTaskProgress.DetailMsg = err.Error() - oldTaskProgress.IsDone = true - serviceContext.UpdateProgress(taskID, oldTaskProgress) - return err + return fail("拉取镜像失败", err) + } + defer reader.Close() + if err := decodePullResp(reader, serviceContext, taskID); err != nil { + return fail("拉取镜像失败", err) } - oldTaskProgress.Message = "容器停止成功" - oldTaskProgress.DetailMsg = "容器停止成功" - oldTaskProgress.Percentage = 40 - serviceContext.UpdateProgress(taskID, oldTaskProgress) - oldTaskProgress.Message = "正在重命名旧容器" - oldTaskProgress.DetailMsg = "正在重命名旧容器" - serviceContext.UpdateProgress(taskID, oldTaskProgress) - currentDate := time.Now().Format("2006-01-02-15-04-05") - err = serviceContext.DockerClient.ContainerRename(context.Background(), id, name+"-"+currentDate) + update(30, "正在读取容器配置", "正在读取容器配置") + inspectedContainer, err := dockerClient.ContainerInspect(ctx, id) if err != nil { - oldTaskProgress.Message = "重命名旧容器失败" - oldTaskProgress.DetailMsg = err.Error() - oldTaskProgress.IsDone = true - serviceContext.UpdateProgress(taskID, oldTaskProgress) - return err + return fail("获取容器信息失败", err) } - oldTaskProgress.Message = "重命名旧容器成功" - oldTaskProgress.DetailMsg = "重命名旧容器成功" - oldTaskProgress.Percentage = 60 - serviceContext.UpdateProgress(taskID, oldTaskProgress) - oldTaskProgress.Message = "正在创建新容器" - oldTaskProgress.DetailMsg = "正在创建新容器" - serviceContext.UpdateProgress(taskID, oldTaskProgress) - inspectedContainer, err := serviceContext.DockerClient.ContainerInspect(ctx, id) - if err != nil { - oldTaskProgress.Message = "获取容器信息失败" - oldTaskProgress.DetailMsg = err.Error() - oldTaskProgress.IsDone = true - serviceContext.UpdateProgress(taskID, oldTaskProgress) - logx.Error("获取容器信息失败" + err.Error()) - return err + if inspectedContainer.Config == nil || inspectedContainer.HostConfig == nil || inspectedContainer.NetworkSettings == nil { + return fail("获取容器信息失败", errors.New("docker 返回的容器配置不完整")) + } + actualName := strings.TrimPrefix(strings.TrimSpace(inspectedContainer.Name), "/") + if actualName == "" || actualName != name { + return fail("容器名称已变化,请刷新后重试", errors.New("请求中的容器名称与 Docker 当前状态不一致")) } - inspectedContainer.Config.Hostname = "" + wasPaused := inspectedContainer.State != nil && inspectedContainer.State.Paused + wasRunning := inspectedContainer.State != nil && (inspectedContainer.State.Running || wasPaused) + clearGeneratedHostname(inspectedContainer.Config, inspectedContainer.ID) inspectedContainer.Config.Image = imageNameAndTag - inspectedContainer.Image = imageNameAndTag config := inspectedContainer.Config hostConfig := inspectedContainer.HostConfig - networkingConfig := &network.NetworkingConfig{ - EndpointsConfig: inspectedContainer.NetworkSettings.Networks, + networkingConfig := networkingConfigForRecreate(inspectedContainer) + + stopTimeout := 10 + if wasRunning { + if wasPaused { + update(35, "正在解除旧容器暂停", "需要先解除暂停才能停止容器") + if err := dockerClient.ContainerUnpause(ctx, id); err != nil { + return fail("解除旧容器暂停失败", err) + } + } + update(40, "正在停止旧容器", "正在停止旧容器") + if err := dockerClient.ContainerStop(ctx, id, container.StopOptions{Timeout: &stopTimeout}); err != nil { + return fail("停止容器失败", err) + } + } else { + update(40, "旧容器已停止", "将保持容器的停止状态") } - containerName := name - _, err = serviceContext.DockerClient.ContainerCreate(ctx, config, hostConfig, networkingConfig, nil, containerName) - if err != nil { - oldTaskProgress.Message = "创建新容器失败" - oldTaskProgress.DetailMsg = err.Error() - oldTaskProgress.IsDone = true - serviceContext.UpdateProgress(taskID, oldTaskProgress) - return err + + backupName := fmt.Sprintf("%s-%s", name, time.Now().Format("2006-01-02-15-04-05.000000000")) + update(50, "正在保留旧容器", "正在重命名旧容器") + if err := dockerClient.ContainerRename(ctx, id, backupName); err != nil { + rollbackErr := runContainerRecovery(func(recoveryCtx context.Context) error { + return restoreOldContainerState(recoveryCtx, dockerClient, id, wasRunning, wasPaused) + }) + return fail("重命名旧容器失败", errors.Join(err, rollbackErr)) } - oldTaskProgress.Message = "创建新容器成功" - oldTaskProgress.DetailMsg = "创建新容器成功" - oldTaskProgress.Percentage = 80 - serviceContext.UpdateProgress(taskID, oldTaskProgress) - oldTaskProgress.Message = "正在启动新容器以及删除旧容器(如果不保留旧容器)" - oldTaskProgress.DetailMsg = "正在启动新容器以及删除旧容器(如果不保留旧容器)" - serviceContext.UpdateProgress(taskID, oldTaskProgress) - err = serviceContext.DockerClient.ContainerStart(context.Background(), containerName, container.StartOptions{ - CheckpointID: "", - CheckpointDir: "", - }) + renamed := true + newContainerID := "" + rollback := func(cause error) error { + rollbackErr := runContainerRecovery(func(recoveryCtx context.Context) error { + return rollbackContainerUpdate(recoveryCtx, dockerClient, id, name, newContainerID, renamed, wasRunning, wasPaused) + }) + return errors.Join(cause, rollbackErr) + } + + update(65, "正在创建新容器", "正在创建新容器") + created, err := dockerClient.ContainerCreate(ctx, config, hostConfig, networkingConfig, nil, name) if err != nil { - oldTaskProgress.Message = "启动新容器失败" - oldTaskProgress.DetailMsg = err.Error() - oldTaskProgress.IsDone = true - serviceContext.UpdateProgress(taskID, oldTaskProgress) - return err + return fail("创建新容器失败,已尝试恢复旧容器", rollback(err)) + } + newContainerID = created.ID + if newContainerID == "" { + return fail("创建新容器失败,已尝试恢复旧容器", rollback(errors.New("docker 未返回新容器 ID"))) } + + if wasRunning { + update(85, "正在启动新容器", "正在启动新容器") + if err := dockerClient.ContainerStart(ctx, newContainerID, container.StartOptions{}); err != nil { + return fail("启动新容器失败,已尝试恢复旧容器", rollback(err)) + } + if wasPaused { + update(90, "正在恢复暂停状态", "旧容器在更新前处于暂停状态") + if err := dockerClient.ContainerPause(ctx, newContainerID); err != nil { + return fail("暂停新容器失败,已尝试恢复旧容器", rollback(err)) + } + } + } else { + update(85, "新容器保持停止", "旧容器更新前处于停止状态") + } + + detail := "更新成功" if delOldContainer { - err = serviceContext.DockerClient.ContainerRemove(context.Background(), id, container.RemoveOptions{}) - if err != nil { - oldTaskProgress.Message = "删除旧容器失败" - oldTaskProgress.DetailMsg = err.Error() - oldTaskProgress.IsDone = true - serviceContext.UpdateProgress(taskID, oldTaskProgress) - return err - } - } - oldTaskProgress.Message = "更新成功" - oldTaskProgress.DetailMsg = "更新成功" - oldTaskProgress.Percentage = 100 - oldTaskProgress.IsDone = true - serviceContext.UpdateProgress(taskID, oldTaskProgress) + if err := dockerClient.ContainerRemove(ctx, id, container.RemoveOptions{}); err != nil { + detail = "新容器已启动,但旧容器删除失败: " + err.Error() + logx.Errorf("新容器已启动,删除旧容器失败: %v", err) + } + } + progress.Message = "更新成功" + progress.DetailMsg = detail + progress.Percentage = 100 + progress.IsDone = true + progress.Status = svc.TaskStatusCompleted + serviceContext.UpdateProgress(taskID, progress) + if serviceContext.HubImageInfo != nil { + serviceContext.HubImageInfo.MarkCurrent(imageNameAndTag) + } return nil } -func decodePullResp(reader io.Reader, ctx *svc.ServiceContext, taskID string) (err error) { - decoder := json.NewDecoder(reader) - var oldTaskProgress, result = ctx.GetProgress(taskID) - if !result { - oldTaskProgress = svc.TaskProgress{ - Percentage: 0, - Name: "", - Message: "", - DetailMsg: "", - IsDone: false, +func networkingConfigForRecreate(inspected container.InspectResponse) *network.NetworkingConfig { + result := &network.NetworkingConfig{EndpointsConfig: make(map[string]*network.EndpointSettings)} + if inspected.NetworkSettings == nil { + return result + } + containerName := strings.TrimPrefix(inspected.Name, "/") + for networkName, endpoint := range inspected.NetworkSettings.Networks { + if endpoint == nil { + continue + } + aliases := make([]string, 0, len(endpoint.Aliases)) + for _, alias := range endpoint.Aliases { + alias = strings.TrimSpace(alias) + generatedIDAlias := inspected.ID != "" && (strings.HasPrefix(inspected.ID, alias) || strings.HasPrefix(alias, inspected.ID)) + if alias == "" || alias == containerName || generatedIDAlias { + continue + } + aliases = append(aliases, alias) + } + var ipamConfig *network.EndpointIPAMConfig + if endpoint.IPAMConfig != nil { + ipamConfig = endpoint.IPAMConfig.Copy() + } + clean := &network.EndpointSettings{ + IPAMConfig: ipamConfig, + Links: append([]string(nil), endpoint.Links...), + Aliases: aliases, + DriverOpts: cloneStringMap(endpoint.DriverOpts), + GwPriority: endpoint.GwPriority, + } + if configuredMAC := legacyConfiguredMAC(inspected.Config); configuredMAC != "" { + clean.MacAddress = configuredMAC + } + result.EndpointsConfig[networkName] = clean + } + return result +} + +func legacyConfiguredMAC(config *container.Config) string { + if config == nil { + return "" + } + //lint:ignore SA1019 Preserve MAC addresses from containers created through Docker API < 1.44. + return config.MacAddress +} + +func cloneStringMap(source map[string]string) map[string]string { + if len(source) == 0 { + return nil + } + result := make(map[string]string, len(source)) + for key, value := range source { + result[key] = value + } + return result +} + +func runContainerRecovery(recoverOperation func(context.Context) error) error { + recoveryCtx, cancel := context.WithTimeout(context.Background(), containerRecoveryTimeout) + defer cancel() + return recoverOperation(recoveryCtx) +} + +func rollbackContainerUpdate(ctx context.Context, dockerClient containerUpdateClient, oldID, originalName, newID string, renamed, wasRunning, wasPaused bool) error { + var rollbackErrors []error + if newID != "" { + if err := dockerClient.ContainerRemove(ctx, newID, container.RemoveOptions{Force: true}); err != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("删除失败的新容器: %w", err)) + } + } + if renamed { + if err := dockerClient.ContainerRename(ctx, oldID, originalName); err != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("恢复旧容器名称: %w", err)) + } + } + if err := restoreOldContainerState(ctx, dockerClient, oldID, wasRunning, wasPaused); err != nil { + rollbackErrors = append(rollbackErrors, err) + } + return errors.Join(rollbackErrors...) +} + +func restoreOldContainerState(ctx context.Context, dockerClient containerUpdateClient, oldID string, wasRunning, wasPaused bool) error { + if err := restartOldContainer(ctx, dockerClient, oldID, wasRunning); err != nil { + return err + } + if wasPaused { + if err := dockerClient.ContainerPause(ctx, oldID); err != nil { + return fmt.Errorf("恢复旧容器暂停状态: %w", err) } } + return nil +} + +func restartOldContainer(ctx context.Context, dockerClient containerUpdateClient, oldID string, wasRunning bool) error { + if !wasRunning { + return nil + } + if err := dockerClient.ContainerStart(ctx, oldID, container.StartOptions{}); err != nil { + return fmt.Errorf("重新启动旧容器: %w", err) + } + return nil +} + +func setContainerTaskFailure(serviceContext *svc.ServiceContext, taskID, name, message string, err error) { + serviceContext.UpdateProgress(taskID, svc.TaskProgress{ + TaskID: taskID, Name: name, Message: message, DetailMsg: err.Error(), + IsDone: true, Status: svc.TaskStatusFailed, + }) +} + +func decodePullResp(reader io.Reader, ctx *svc.ServiceContext, taskID string) error { + decoder := json.NewDecoder(reader) + progress, exists := ctx.GetProgress(taskID) + if !exists { + progress = svc.TaskProgress{TaskID: taskID, Status: svc.TaskStatusRunning} + } for { var msg dockerMsgType.JSONMessage - if err = decoder.Decode(&msg); err != nil { + if err := decoder.Decode(&msg); err != nil { if err == io.EOF { return nil } - oldTaskProgress.Message = "拉取镜像失败" - oldTaskProgress.DetailMsg = err.Error() - oldTaskProgress.Percentage = 25 - oldTaskProgress.IsDone = true - ctx.UpdateProgress(taskID, oldTaskProgress) - logx.Errorf("Failed to decode pull image response: %s", err) - return fmt.Errorf("拉取镜像失败: %w", err) - } - // Print the progress or error information from the response + return fmt.Errorf("解析拉取镜像响应失败: %w", err) + } if msg.Error != nil { - oldTaskProgress.Message = "拉取镜像失败" - oldTaskProgress.DetailMsg = msg.Error.Error() - oldTaskProgress.Percentage = 25 - oldTaskProgress.IsDone = true - ctx.UpdateProgress(taskID, oldTaskProgress) - logx.Errorf("Error: %s", msg.Error) return fmt.Errorf("拉取镜像失败: %w", msg.Error) - } else { - var formattedMsg string - if msg.Progress != nil { - formattedMsg = fmt.Sprintf("进度%s: %s", msg.Status, msg.Progress.String()) - } else { - formattedMsg = fmt.Sprintf("进度%s", msg.Status) - } - oldTaskProgress.DetailMsg = formattedMsg - oldTaskProgress.Percentage = 25 - ctx.UpdateProgress(taskID, oldTaskProgress) - logx.Infof("拉取镜像进度\t %s: %s\n", msg.Status, msg.Progress) } + formattedMsg := "进度" + msg.Status + if msg.Progress != nil { + formattedMsg = fmt.Sprintf("进度%s: %s", msg.Status, msg.Progress.String()) + } + progress.DetailMsg = formattedMsg + progress.Percentage = 25 + progress.Status = svc.TaskStatusRunning + ctx.UpdateProgress(taskID, progress) } } diff --git a/internal/utiles/updatecontainer_test.go b/internal/utiles/updatecontainer_test.go new file mode 100644 index 00000000..29771588 --- /dev/null +++ b/internal/utiles/updatecontainer_test.go @@ -0,0 +1,260 @@ +package utiles + +import ( + "context" + "errors" + "io" + "strings" + "testing" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/network" + "github.com/onlyLTY/dockerCopilot/internal/module" + "github.com/onlyLTY/dockerCopilot/internal/svc" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +type fakeContainerUpdateClient struct { + events []string + createErr error + startNewErr error + removeOldErr error + cancelOnCreate context.CancelFunc + recoveryContextErrors []error + pullOptions image.PullOptions + stopped bool + paused bool + createdNetworking *network.NetworkingConfig +} + +func (f *fakeContainerUpdateClient) ImagePull(_ context.Context, _ string, options image.PullOptions) (io.ReadCloser, error) { + f.events = append(f.events, "pull") + f.pullOptions = options + return io.NopCloser(strings.NewReader("{}\n")), nil +} + +func TestUpdateContainerUsesRegistryAuthentication(t *testing.T) { + t.Setenv("DOCKER_AUTH_CONFIG", `{"auths":{"registry.example":{"auth":"dXNlcjpwYXNz"}}}`) + client := &fakeContainerUpdateClient{} + serviceContext := testUpdateServiceContext() + if err := updateContainer(context.Background(), serviceContext, client, "old", "app", "registry.example/team/app:latest", false, "task"); err != nil { + t.Fatal(err) + } + if client.pullOptions.RegistryAuth == "" { + t.Fatal("private registry credentials were not passed to ImagePull") + } +} + +func (f *fakeContainerUpdateClient) ContainerInspect(context.Context, string) (container.InspectResponse, error) { + f.events = append(f.events, "inspect") + return container.InspectResponse{ + ContainerJSONBase: &container.ContainerJSONBase{ + ID: "old", Name: "/app", Image: "sha256:old", State: &container.State{Running: !f.stopped, Paused: f.paused}, + HostConfig: &container.HostConfig{}, + }, + Config: &container.Config{Image: "repo:old"}, + NetworkSettings: &container.NetworkSettings{Networks: map[string]*network.EndpointSettings{}}, + }, nil +} + +func TestUpdateContainerRejectsStaleContainerName(t *testing.T) { + client := &fakeContainerUpdateClient{} + serviceContext := testUpdateServiceContext() + if err := updateContainer(context.Background(), serviceContext, client, "old", "stale-name", "repo:new", true, "task"); err == nil { + t.Fatal("expected stale container name to be rejected") + } + for _, event := range client.events { + if event == "stop-old" { + t.Fatal("container was stopped before stale name was rejected") + } + } +} + +func (f *fakeContainerUpdateClient) ContainerStop(context.Context, string, container.StopOptions) error { + f.events = append(f.events, "stop-old") + return nil +} + +func (f *fakeContainerUpdateClient) ContainerPause(_ context.Context, id string) error { + f.events = append(f.events, "pause-"+id) + return nil +} + +func (f *fakeContainerUpdateClient) ContainerUnpause(_ context.Context, id string) error { + f.events = append(f.events, "unpause-"+id) + return nil +} + +func (f *fakeContainerUpdateClient) ContainerRename(ctx context.Context, id, name string) error { + if name == "app" { + f.events = append(f.events, "rename-old-back") + f.recoveryContextErrors = append(f.recoveryContextErrors, ctx.Err()) + } else { + f.events = append(f.events, "rename-old-away") + } + return nil +} + +func (f *fakeContainerUpdateClient) ContainerCreate(_ context.Context, config *container.Config, _ *container.HostConfig, networking *network.NetworkingConfig, _ *ocispec.Platform, _ string) (container.CreateResponse, error) { + f.events = append(f.events, "create-"+config.Image) + f.createdNetworking = networking + if f.cancelOnCreate != nil { + f.cancelOnCreate() + } + if f.createErr != nil { + return container.CreateResponse{}, f.createErr + } + return container.CreateResponse{ID: "new"}, nil +} + +func (f *fakeContainerUpdateClient) ContainerStart(ctx context.Context, id string, _ container.StartOptions) error { + f.events = append(f.events, "start-"+id) + if id == "old" { + f.recoveryContextErrors = append(f.recoveryContextErrors, ctx.Err()) + } + if id == "new" && f.startNewErr != nil { + return f.startNewErr + } + return nil +} + +func (f *fakeContainerUpdateClient) ContainerRemove(_ context.Context, id string, options container.RemoveOptions) error { + f.events = append(f.events, "remove-"+id) + if id == "old" { + return f.removeOldErr + } + if id == "new" && !options.Force { + return errors.New("rollback removal must be forced") + } + return nil +} + +func TestUpdateContainerRollsBackCreateFailure(t *testing.T) { + client := &fakeContainerUpdateClient{createErr: errors.New("create failed")} + serviceContext := testUpdateServiceContext() + err := updateContainer(context.Background(), serviceContext, client, "old", "app", "repo:new", true, "task") + if err == nil { + t.Fatal("expected create failure") + } + assertEventOrder(t, client.events, "rename-old-away", "create-repo:new", "rename-old-back", "start-old") + assertTaskStatus(t, serviceContext, svc.TaskStatusFailed) +} + +func TestUpdateContainerRollsBackStartFailure(t *testing.T) { + client := &fakeContainerUpdateClient{startNewErr: errors.New("start failed")} + serviceContext := testUpdateServiceContext() + err := updateContainer(context.Background(), serviceContext, client, "old", "app", "repo:new", true, "task") + if err == nil { + t.Fatal("expected start failure") + } + assertEventOrder(t, client.events, "start-new", "remove-new", "rename-old-back", "start-old") + assertTaskStatus(t, serviceContext, svc.TaskStatusFailed) +} + +func TestUpdateContainerUsesFreshContextForRollback(t *testing.T) { + primaryContext, cancel := context.WithCancel(context.Background()) + client := &fakeContainerUpdateClient{ + createErr: errors.New("create failed"), + cancelOnCreate: cancel, + } + serviceContext := testUpdateServiceContext() + if err := updateContainer(primaryContext, serviceContext, client, "old", "app", "repo:new", true, "task"); err == nil { + t.Fatal("expected create failure") + } + if len(client.recoveryContextErrors) != 2 { + t.Fatalf("expected rename and restart recovery calls, got %d", len(client.recoveryContextErrors)) + } + for _, contextErr := range client.recoveryContextErrors { + if contextErr != nil { + t.Fatalf("rollback reused cancelled context: %v", contextErr) + } + } +} + +func TestUpdateContainerKeepsSuccessfulReplacementWhenOldRemovalFails(t *testing.T) { + client := &fakeContainerUpdateClient{removeOldErr: errors.New("busy")} + serviceContext := testUpdateServiceContext() + if err := updateContainer(context.Background(), serviceContext, client, "old", "app", "repo:new", true, "task"); err != nil { + t.Fatalf("replacement succeeded but returned error: %v", err) + } + progress, _ := serviceContext.GetProgress("task") + if progress.Status != svc.TaskStatusCompleted || !strings.Contains(progress.DetailMsg, "旧容器删除失败") { + t.Fatalf("unexpected progress: %+v", progress) + } +} + +func TestUpdateContainerPreservesStoppedState(t *testing.T) { + client := &fakeContainerUpdateClient{stopped: true} + serviceContext := testUpdateServiceContext() + if err := updateContainer(context.Background(), serviceContext, client, "old", "app", "repo:new", true, "task"); err != nil { + t.Fatal(err) + } + for _, event := range client.events { + if event == "stop-old" || event == "start-new" { + t.Fatalf("stopped container state was not preserved: %v", client.events) + } + } +} + +func TestUpdateContainerPreservesPausedState(t *testing.T) { + client := &fakeContainerUpdateClient{paused: true} + serviceContext := testUpdateServiceContext() + if err := updateContainer(context.Background(), serviceContext, client, "old", "app", "repo:new", true, "task"); err != nil { + t.Fatal(err) + } + assertEventOrder(t, client.events, "unpause-old", "stop-old", "start-new", "pause-new") +} + +func TestNetworkingConfigForRecreateDropsOperationalFields(t *testing.T) { + inspected := container.InspectResponse{ + ContainerJSONBase: &container.ContainerJSONBase{ID: "abcdefabcdef1234", Name: "/app"}, + Config: &container.Config{}, + NetworkSettings: &container.NetworkSettings{Networks: map[string]*network.EndpointSettings{ + "project_default": { + IPAMConfig: &network.EndpointIPAMConfig{IPv4Address: "172.20.0.10"}, + Aliases: []string{"app", "abcdefabcdef", "custom-alias"}, + NetworkID: "runtime-network-id", EndpointID: "runtime-endpoint-id", + IPAddress: "172.20.0.10", Gateway: "172.20.0.1", MacAddress: "02:42:ac:14:00:0a", + }, + }}, + } + clean := networkingConfigForRecreate(inspected).EndpointsConfig["project_default"] + if clean == nil || clean.IPAMConfig == nil || clean.IPAMConfig.IPv4Address != "172.20.0.10" { + t.Fatalf("configured IPAM settings were lost: %+v", clean) + } + if clean.NetworkID != "" || clean.EndpointID != "" || clean.IPAddress != "" || clean.Gateway != "" || clean.MacAddress != "" { + t.Fatalf("runtime-only network fields leaked into create request: %+v", clean) + } + if len(clean.Aliases) != 1 || clean.Aliases[0] != "custom-alias" { + t.Fatalf("generated aliases were not filtered: %+v", clean.Aliases) + } +} + +func testUpdateServiceContext() *svc.ServiceContext { + return &svc.ServiceContext{ + ProgressStore: make(svc.ProgressStoreType), + HubImageInfo: module.NewImageCheck(), + } +} + +func assertTaskStatus(t *testing.T, serviceContext *svc.ServiceContext, expected string) { + t.Helper() + progress, exists := serviceContext.GetProgress("task") + if !exists || progress.Status != expected || !progress.IsDone { + t.Fatalf("unexpected task progress: %+v, exists=%v", progress, exists) + } +} + +func assertEventOrder(t *testing.T, events []string, expected ...string) { + t.Helper() + next := 0 + for _, event := range events { + if next < len(expected) && event == expected[next] { + next++ + } + } + if next != len(expected) { + t.Fatalf("events %v do not contain ordered sequence %v", events, expected) + } +} diff --git a/internal/utiles/updateprogram.go b/internal/utiles/updateprogram.go index 81f633d6..7cb23a75 100644 --- a/internal/utiles/updateprogram.go +++ b/internal/utiles/updateprogram.go @@ -3,153 +3,368 @@ package utiles import ( "archive/tar" "compress/gzip" + "context" + "crypto/sha256" + "debug/elf" + "encoding/hex" + "errors" "fmt" - "github.com/onlyLTY/dockerCopilot/internal/svc" - "github.com/zeromicro/go-zero/core/logx" "io" "net/http" + "net/url" "os" + "path/filepath" + "regexp" "runtime" + "strconv" "strings" + "time" ) -func UpdateProgram(ctx *svc.ServiceContext) error { - githubProxy := os.Getenv("githubProxy") - if githubProxy != "" { - githubProxy = strings.TrimRight(githubProxy, "/") + "/" +const ( + maxUpdateArchiveSize int64 = 256 << 20 + maxUpdateExtractedSize int64 = 512 << 20 + maxChecksumFileSize int64 = 4 << 10 +) + +var ErrAlreadyLatest = errors.New("当前已是最新版本") +var ErrImageManagedUpdate = errors.New("当前部署由容器镜像管理,请拉取新镜像并重新创建容器") +var ErrRemoteVersionNotNewer = errors.New("远端版本不高于当前版本,拒绝更新") + +var semanticVersionPattern = regexp.MustCompile(`^[vV]?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$`) + +func UpdateProgram(ctx context.Context) error { + if BinarySelfUpdateDisabled() { + return ErrImageManagedUpdate } - versionURL := githubProxy + "https://raw.githubusercontent.com/onlyLTY/dockerCopilot/UGREEN/version" - releaseBaseURL := githubProxy + "https://github.com/onlyLTY/dockerCopilot/releases/download" - logx.Infof("versionURL: %s", versionURL) - resp, err := http.Get(versionURL) + version, err := GetRemoteVersion(ctx) if err != nil { - logx.Info("没有获取到最新版本信息:", err) - return nil + return fmt.Errorf("获取最新版本失败: %w", err) + } + if version == currentVersion() { + return ErrAlreadyLatest + } + newer, err := isNewerVersion(currentVersion(), version) + if err != nil { + return fmt.Errorf("比较版本失败: %w", err) + } + if !newer { + return ErrRemoteVersionNotNewer } - defer func(Body io.ReadCloser) { - err := Body.Close() - if err != nil { - logx.Error("关闭resp.Body失败:", err) - } - }(resp.Body) - versionData, err := io.ReadAll(resp.Body) - logx.Infof("versionData: %s", versionData) + repository, err := updateRepository() if err != nil { - logx.Info("没有获取到最新版本信息:", err) - return nil + return err + } + releaseBaseURL, err := githubURL("https://github.com/" + repository + "/releases/download") + if err != nil { + return err } + archiveName := fmt.Sprintf("dockerCopilot-%s.tar.gz", runtime.GOARCH) + downloadURL := fmt.Sprintf("%s/%s/%s", strings.TrimRight(releaseBaseURL, "/"), url.PathEscape(version), archiveName) + checksumURL := downloadURL + ".sha256" - version := strings.TrimSpace(string(versionData)) - logx.Info("获取到最新版本:", version) - // 2. 构造下载链接 - downloadURL := fmt.Sprintf("%s/%s/dockerCopilot-%s.tar.gz", releaseBaseURL, version, runtime.GOARCH) - logx.Info("下载链接:", downloadURL) - dest := "dockerCopilot.tar.gz" + executablePath, err := os.Executable() + if err != nil { + return fmt.Errorf("定位当前程序失败: %w", err) + } + appDir := filepath.Dir(executablePath) + stagingDir, err := os.MkdirTemp(appDir, ".dockercopilot-update-*") + if err != nil { + return fmt.Errorf("创建更新暂存目录失败: %w", err) + } + defer os.RemoveAll(stagingDir) - if err := downloadFile(downloadURL, dest); err != nil { - logx.Error("下载失败:", err) - return err + httpClient := newHTTPSClient(2 * time.Minute) + archivePath := filepath.Join(stagingDir, archiveName) + checksumPath := archivePath + ".sha256" + if err := downloadFile(ctx, httpClient, downloadURL, archivePath, maxUpdateArchiveSize); err != nil { + return fmt.Errorf("下载更新包失败: %w", err) + } + if err := downloadFile(ctx, httpClient, checksumURL, checksumPath, maxChecksumFileSize); err != nil { + return fmt.Errorf("下载更新校验文件失败: %w", err) + } + if err := verifySHA256(archivePath, checksumPath); err != nil { + return fmt.Errorf("更新包校验失败: %w", err) } - logx.Info("下载成功") - if err := decompressTarGz(dest, "."); err != nil { - logx.Info("解压缩失败:", err) + extractDir := filepath.Join(stagingDir, "extract") + if err := os.Mkdir(extractDir, 0o700); err != nil { + return fmt.Errorf("创建解压目录失败: %w", err) + } + if err := decompressTarGz(archivePath, extractDir); err != nil { + return fmt.Errorf("解压更新包失败: %w", err) + } + + newBinary := filepath.Join(extractDir, "dockerCopilot-new") + info, err := os.Lstat(newBinary) + if err != nil { + return fmt.Errorf("更新包缺少 dockerCopilot-new: %w", err) + } + if !info.Mode().IsRegular() || info.Size() == 0 { + return errors.New("更新程序不是有效的普通文件") + } + if err := validateLinuxBinary(newBinary); err != nil { return err } - logx.Info("解压缩成功") + // #nosec G302 -- the staged application must be executable; 0700 restricts it to the service user. + if err := os.Chmod(newBinary, 0o700); err != nil { + return fmt.Errorf("设置更新程序权限失败: %w", err) + } + pendingPath := filepath.Join(appDir, "dockerCopilot-new") + if err := os.Remove(pendingPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("清理旧更新文件失败: %w", err) + } + if err := os.Rename(newBinary, pendingPath); err != nil { + return fmt.Errorf("安装更新文件失败: %w", err) + } return nil } -func downloadFile(url string, dest string) error { - resp, err := http.Get(url) +func BinarySelfUpdateDisabled() bool { + raw := strings.TrimSpace(os.Getenv("DISABLE_BINARY_SELF_UPDATE")) + if raw == "" { + return false + } + disabled, err := strconv.ParseBool(raw) + return err != nil || disabled +} + +func isNewerVersion(current, remote string) (bool, error) { + currentParts, err := parseSemanticVersion(current) if err != nil { - return err + return false, err + } + remoteParts, err := parseSemanticVersion(remote) + if err != nil { + return false, err } - defer func(Body io.ReadCloser) { - err := Body.Close() + for index := range currentParts { + if remoteParts[index] != currentParts[index] { + return remoteParts[index] > currentParts[index], nil + } + } + return false, nil +} + +func parseSemanticVersion(value string) ([3]uint64, error) { + match := semanticVersionPattern.FindStringSubmatch(strings.TrimSpace(value)) + if len(match) != 4 { + return [3]uint64{}, fmt.Errorf("版本 %q 不是支持的语义版本", value) + } + var result [3]uint64 + for index := range result { + component, err := strconv.ParseUint(match[index+1], 10, 64) if err != nil { - logx.Error("关闭resp.Body失败:", err) + return [3]uint64{}, fmt.Errorf("版本 %q 格式错误", value) } - }(resp.Body) + result[index] = component + } + return result, nil +} + +func downloadFile(ctx context.Context, client *http.Client, rawURL, dest string, maxBytes int64) (retErr error) { + parsedURL, err := url.Parse(rawURL) + if err != nil || parsedURL.Scheme != "https" || parsedURL.Host == "" { + return errors.New("仅允许有效的 HTTPS 下载地址") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsedURL.String(), nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("服务器返回 %s", resp.Status) + } + if resp.ContentLength > maxBytes { + return errors.New("响应大小超过限制") + } - out, err := os.Create(dest) + destination, err := filepath.Abs(dest) if err != nil { return err } - defer func(out *os.File) { - err := out.Close() - if err != nil { - logx.Error("关闭out失败:", err) + destinationRoot, err := os.OpenRoot(filepath.Dir(destination)) + if err != nil { + return err + } + defer destinationRoot.Close() + destinationName := filepath.Base(destination) + out, err := destinationRoot.OpenFile(destinationName, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return err + } + defer func() { + if closeErr := out.Close(); retErr == nil && closeErr != nil { + retErr = closeErr + } + if retErr != nil { + _ = destinationRoot.Remove(destinationName) } - }(out) + }() - _, err = io.Copy(out, resp.Body) - return err + written, err := io.Copy(out, io.LimitReader(resp.Body, maxBytes+1)) + if err != nil { + return err + } + if written > maxBytes { + return errors.New("响应大小超过限制") + } + return nil } -func decompressTarGz(gzFilePath string, dest string) error { - file, err := os.Open(gzFilePath) +func verifySHA256(archivePath, checksumPath string) error { + checksum, checksumRoot, err := openFileWithinParent(checksumPath) if err != nil { return err } - defer func(file *os.File) { - err := file.Close() - if err != nil { - logx.Error("关闭file失败:", err) - } - }(file) + defer checksumRoot.Close() + defer checksum.Close() + checksumData, err := io.ReadAll(io.LimitReader(checksum, maxChecksumFileSize+1)) + if err != nil { + return err + } + if int64(len(checksumData)) > maxChecksumFileSize { + return errors.New("SHA-256 文件超过大小限制") + } + fields := strings.Fields(string(checksumData)) + if len(fields) == 0 || len(fields[0]) != sha256.Size*2 { + return errors.New("SHA-256 文件格式错误") + } + expected, err := hex.DecodeString(fields[0]) + if err != nil { + return errors.New("SHA-256 文件格式错误") + } + + archive, archiveRoot, err := openFileWithinParent(archivePath) + if err != nil { + return err + } + defer archiveRoot.Close() + defer archive.Close() + hash := sha256.New() + if _, err := io.Copy(hash, archive); err != nil { + return err + } + if !equalBytes(hash.Sum(nil), expected) { + return errors.New("SHA-256 不匹配") + } + return nil +} + +func equalBytes(a, b []byte) bool { + if len(a) != len(b) { + return false + } + var result byte + for index := range a { + result |= a[index] ^ b[index] + } + return result == 0 +} + +func decompressTarGz(gzFilePath, dest string) error { + file, archiveRoot, err := openFileWithinParent(gzFilePath) + if err != nil { + return err + } + defer archiveRoot.Close() + defer file.Close() gzr, err := gzip.NewReader(file) if err != nil { return err } - defer func(gzr *gzip.Reader) { - err := gzr.Close() - if err != nil { - logx.Error("关闭gzr失败:", err) - } - }(gzr) + defer gzr.Close() + destinationRoot, err := os.OpenRoot(dest) + if err != nil { + return err + } + defer destinationRoot.Close() tarReader := tar.NewReader(gzr) - + var extracted int64 for { header, err := tarReader.Next() if err == io.EOF { - break + return nil } if err != nil { return err } + if header.Size < 0 || header.Size > maxUpdateExtractedSize-extracted { + return errors.New("解压内容超过大小限制") + } + extracted += header.Size - target := dest + "/" + header.Name - + cleanName := filepath.Clean(filepath.FromSlash(header.Name)) + if cleanName == "." || filepath.IsAbs(cleanName) || cleanName == ".." || strings.HasPrefix(cleanName, ".."+string(os.PathSeparator)) { + return fmt.Errorf("压缩包包含非法路径 %q", header.Name) + } switch header.Typeflag { case tar.TypeDir: - if err := os.MkdirAll(target, os.FileMode(header.Mode)); err != nil { + if err := destinationRoot.MkdirAll(cleanName, 0o700); err != nil { return err } - case tar.TypeReg: - outFile, err := os.Create(target) - if err != nil { + case tar.TypeReg, byte(0): + if err := destinationRoot.MkdirAll(filepath.Dir(cleanName), 0o700); err != nil { return err } - if _, err := io.Copy(outFile, tarReader); err != nil { - err := outFile.Close() - if err != nil { - return err - } - return err - } - err = outFile.Close() + outFile, err := destinationRoot.OpenFile(cleanName, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if err != nil { return err } + _, copyErr := io.CopyN(outFile, tarReader, header.Size) + closeErr := outFile.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } default: - return fmt.Errorf("未知类型: %v in %s", header.Typeflag, header.Name) + return fmt.Errorf("压缩包包含不支持的文件类型 %d", header.Typeflag) } } +} +func openFileWithinParent(path string) (*os.File, *os.Root, error) { + absolutePath, err := filepath.Abs(path) + if err != nil { + return nil, nil, err + } + root, err := os.OpenRoot(filepath.Dir(absolutePath)) + if err != nil { + return nil, nil, err + } + file, err := root.Open(filepath.Base(absolutePath)) + if err != nil { + _ = root.Close() + return nil, nil, err + } + return file, root, nil +} + +func validateLinuxBinary(path string) error { + if runtime.GOOS != "linux" { + return nil + } + binary, err := elf.Open(path) + if err != nil { + return fmt.Errorf("更新程序不是有效的 ELF 文件: %w", err) + } + defer binary.Close() + expectedMachine := map[string]elf.Machine{ + "amd64": elf.EM_X86_64, + "arm64": elf.EM_AARCH64, + }[runtime.GOARCH] + if expectedMachine == elf.EM_NONE || binary.Machine != expectedMachine { + return fmt.Errorf("更新程序架构不匹配: %s", binary.Machine) + } return nil } diff --git a/internal/utiles/updateprogram_test.go b/internal/utiles/updateprogram_test.go new file mode 100644 index 00000000..658d0fef --- /dev/null +++ b/internal/utiles/updateprogram_test.go @@ -0,0 +1,116 @@ +package utiles + +import ( + "archive/tar" + "compress/gzip" + "context" + "crypto/sha256" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDecompressTarGzRejectsTraversal(t *testing.T) { + archivePath := filepath.Join(t.TempDir(), "update.tar.gz") + writeTestTarGz(t, archivePath, "../outside", "owned") + destination := t.TempDir() + + if err := decompressTarGz(archivePath, destination); err == nil { + t.Fatal("expected traversal archive to be rejected") + } + if _, err := os.Stat(filepath.Join(filepath.Dir(destination), "outside")); !os.IsNotExist(err) { + t.Fatalf("archive escaped destination: %v", err) + } +} + +func TestDecompressTarGzExtractsRegularFile(t *testing.T) { + archivePath := filepath.Join(t.TempDir(), "update.tar.gz") + writeTestTarGz(t, archivePath, "dockerCopilot-new", "binary") + destination := t.TempDir() + + if err := decompressTarGz(archivePath, destination); err != nil { + t.Fatalf("decompressTarGz returned error: %v", err) + } + content, err := os.ReadFile(filepath.Join(destination, "dockerCopilot-new")) + if err != nil { + t.Fatalf("failed to read extracted file: %v", err) + } + if string(content) != "binary" { + t.Fatalf("unexpected extracted content %q", content) + } +} + +func TestVerifySHA256(t *testing.T) { + dir := t.TempDir() + archivePath := filepath.Join(dir, "archive") + checksumPath := filepath.Join(dir, "archive.sha256") + content := []byte("verified update") + if err := os.WriteFile(archivePath, content, 0o600); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(content) + if err := os.WriteFile(checksumPath, []byte(fmt.Sprintf("%x archive\n", digest)), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySHA256(archivePath, checksumPath); err != nil { + t.Fatalf("valid checksum rejected: %v", err) + } + if err := os.WriteFile(archivePath, []byte("tampered"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySHA256(archivePath, checksumPath); err == nil { + t.Fatal("tampered archive accepted") + } +} + +func TestDownloadFileEnforcesLimitAndStatus(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/large": + _, _ = w.Write([]byte(strings.Repeat("x", 9))) + default: + http.Error(w, "missing", http.StatusNotFound) + } + })) + defer server.Close() + + largePath := filepath.Join(t.TempDir(), "large") + if err := downloadFile(context.Background(), server.Client(), server.URL+"/large", largePath, 8); err == nil { + t.Fatal("oversized response accepted") + } + if _, err := os.Stat(largePath); !os.IsNotExist(err) { + t.Fatalf("partial oversized file was not removed: %v", err) + } + if err := downloadFile(context.Background(), server.Client(), server.URL+"/missing", filepath.Join(t.TempDir(), "missing"), 8); err == nil { + t.Fatal("non-200 response accepted") + } +} + +func writeTestTarGz(t *testing.T, path, name, content string) { + t.Helper() + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + gzipWriter := gzip.NewWriter(file) + tarWriter := tar.NewWriter(gzipWriter) + if err := tarWriter.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(content)), Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + if _, err := tarWriter.Write([]byte(content)); err != nil { + t.Fatal(err) + } + if err := tarWriter.Close(); err != nil { + t.Fatal(err) + } + if err := gzipWriter.Close(); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/utiles/versioncompare_test.go b/internal/utiles/versioncompare_test.go new file mode 100644 index 00000000..91c63501 --- /dev/null +++ b/internal/utiles/versioncompare_test.go @@ -0,0 +1,32 @@ +package utiles + +import "testing" + +func TestIsNewerVersion(t *testing.T) { + tests := []struct { + current string + remote string + want bool + }{ + {"v2.1.3", "v2.1.4", true}, + {"v2.1.3-FNOS", "v2.2.0-FNOS", true}, + {"v2.1.3", "v2.1.3", false}, + {"v2.2.0", "v2.1.9", false}, + } + for _, test := range tests { + got, err := isNewerVersion(test.current, test.remote) + if err != nil { + t.Fatalf("compare %s and %s: %v", test.current, test.remote, err) + } + if got != test.want { + t.Fatalf("compare %s and %s: got %v want %v", test.current, test.remote, got, test.want) + } + } +} + +func TestBinarySelfUpdateDisabledFailsClosed(t *testing.T) { + t.Setenv("DISABLE_BINARY_SELF_UPDATE", "not-a-boolean") + if !BinarySelfUpdateDisabled() { + t.Fatal("invalid container update configuration must fail closed") + } +} diff --git a/start.sh b/start.sh index 828a37d8..b237f120 100644 --- a/start.sh +++ b/start.sh @@ -1,12 +1,23 @@ #!/bin/sh -cd "${WORKDIR}" || exit +set -eu + +APP_DIR=$(CDPATH= cd "$(dirname "$0")" && pwd) +cd "$APP_DIR" # 判断当前目录下是否存在名为 dockerCopilot-new 的二进制文件 if [ -f "./dockerCopilot-new" ]; then - # 如果存在,则用它覆盖 dockerCopilot - mv ./dockerCopilot-new ./dockerCopilot - # 赋予 dockerCopilot 执行权限 - chmod +x ./dockerCopilot + # 新文件必须先具备安全的执行权限,避免替换后因 chmod 失败而无法启动。 + chmod 0700 ./dockerCopilot-new + # 先保留当前版本;安装动作失败时立即恢复,避免留下不可启动的目录。 + if [ -f "./dockerCopilot" ]; then + mv -f ./dockerCopilot ./dockerCopilot-old + if ! mv -f ./dockerCopilot-new ./dockerCopilot; then + mv -f ./dockerCopilot-old ./dockerCopilot + exit 1 + fi + else + mv -f ./dockerCopilot-new ./dockerCopilot + fi fi # 运行 dockerCopilot -./dockerCopilot +exec ./dockerCopilot