Author SHA1 Message Date
rogeeandmultica-agent 175c309fcb HH-690: hold data lock through warm startup
Docker image / Test (pull_request) Successful in 1m43s
Docker image / Build and publish (pull_request) Successful in 9m25s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-26 15:56:09 +08:00
rogeeandmultica-agent e1136c6a0b HH-690: fail closed on concurrent lifecycle updates
Docker image / Test (pull_request) Successful in 1m59s
Docker image / Build and publish (pull_request) Successful in 2m23s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-26 15:00:55 +08:00
rogeeandmultica-agent 0d3095fde3 HH-690: probe controller from Docker host
Docker image / Test (pull_request) Successful in 1m17s
Docker image / Build and publish (pull_request) Successful in 2m35s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-26 14:43:29 +08:00
rogeeandmultica-agent ee87a53777 HH-690: make smoke secret daemon-visible
Docker image / Test (pull_request) Failing after 1m28s
Docker image / Build and publish (pull_request) Skipped
Co-authored-by: multica-agent <github@multica.ai>
2026-08-26 14:37:16 +08:00
rogeeandmultica-agent 0814e80aa4 HH-690: add single-container Mihomo lifecycle
Docker image / Test (pull_request) Failing after 8m20s
Docker image / Build and publish (pull_request) Skipped
Co-authored-by: multica-agent <github@multica.ai>
2026-08-26 13:19:22 +08:00
5 changed files with 22 additions and 187 deletions
-64
View File
@@ -1,64 +0,0 @@
name: Docker image (Gitea)
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
permissions:
contents: read
jobs:
test:
name: Test and build
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: false
- name: Run tests
run: ./scripts/test.sh
- name: Build and run container smoke test
run: ./tests/container-smoke.sh
- name: Log in to Gitea Container Registry
if: gitea.event_name != 'pull_request'
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: printf '%s' "$REGISTRY_TOKEN" | docker login git.ipao.vip --username "${{ gitea.actor }}" --password-stdin
- name: Publish tested image
if: gitea.event_name != 'pull_request'
env:
IMAGE_NAME: git.ipao.vip/rogee/mohomo-docker
SHA_TAG: sha-${{ gitea.sha }}
run: |
docker tag mohomo-docker:smoke "$IMAGE_NAME:latest"
docker tag mohomo-docker:smoke "$IMAGE_NAME:$SHA_TAG"
docker push "$IMAGE_NAME:latest"
docker push "$IMAGE_NAME:$SHA_TAG"
- name: Link package to repository
if: gitea.event_name != 'pull_request'
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
package_api=https://git.ipao.vip/api/v1/packages/rogee/container/mohomo-docker
linked_repo="$(curl --fail --silent --show-error --header "Authorization: token $REGISTRY_TOKEN" "$package_api/-/latest" | jq -r '.repository.full_name // empty')"
if [ "$linked_repo" != rogee/mohomo-docker ]; then
curl --fail --silent --show-error --request POST \
--header "Authorization: token $REGISTRY_TOKEN" \
"$package_api/-/link/mohomo-docker"
fi
test "$(curl --fail --silent --show-error --header "Authorization: token $REGISTRY_TOKEN" "$package_api/-/latest" | jq -r '.repository.full_name // empty')" = rogee/mohomo-docker
+1 -1
View File
@@ -1,4 +1,4 @@
name: Docker image (GitHub)
name: Docker image
on:
push:
+1 -1
View File
@@ -16,4 +16,4 @@ down:
docker compose down
logs:
docker compose logs -f mihomo
docker compose logs -f ssclash
+6 -61
View File
@@ -13,8 +13,6 @@ docker compose up -d --build
`subscription.url` must contain exactly one absolute HTTP(S) URL without URL userinfo. Compose mounts it read-only at `/run/secrets/subscription`; the URL is never passed in the environment or written to the image, volume, generated configuration, or logs. Keep this file out of Git.
The host file should remain mode `0600`; Compose exposes it inside the container as a read-only secret. Do not put the URL in `.env`, command-line arguments, Compose YAML, or support logs. The checked-in `.gitignore` and `.dockerignore` exclude the default secret filename, but operators remain responsible for protecting custom secret paths.
Open `http://127.0.0.1:9090/ui/` for the packaged MetaCubeXD interface. It uses Mihomo's controller and proxy-group APIs to inspect status and switch nodes. Proxy clients use:
```text
@@ -25,80 +23,27 @@ Controller: http://127.0.0.1:9090
Both ports bind to host loopback by default. `PROXY_BIND`, `PROXY_PORT`, `CONTROLLER_BIND`, and `CONTROLLER_PORT` are optional overrides. Expose port 9090 only to a trusted network or authenticated reverse proxy; this minimal deployment intentionally does not add a second authentication layer.
In MetaCubeXD, open the `🚀 节点选择` group and choose a node or policy. The choice applies to the current Mihomo process only; `profile.store-selected` is disabled, so a restart returns to the configured default.
## Data and restart recovery
Compose mounts the `mihomo-data` named volume at `/data`. It contains generated configuration and normalized subscription data, including node credentials, with private container-side permissions. Treat the volume as sensitive: do not copy it into images, source control, unencrypted backups, or support bundles.
`/data/last-good` is a managed relative symlink to `/data/generations/a` or `/data/generations/b`. Keep the same Compose project and named volume across upgrades and restarts. Do not use `docker compose down --volumes` unless intentionally deleting the cached configuration; removing the volume makes the next start a cold start that requires the subscription endpoint to be reachable.
Normal recovery uses the existing volume:
```sh
docker compose restart mihomo
docker compose ps
```
## Lifecycle and updates
On a fresh volume, bootstrap downloads, normalizes, generates, and validates a candidate with the packaged Mihomo binary before starting Mihomo. This is the cold-start update; a failure exits nonzero without starting an empty configuration.
On a fresh volume, bootstrap downloads, normalizes, generates, and validates a candidate with the packaged Mihomo binary before starting Mihomo. A failure exits nonzero without starting an empty configuration.
On restart, bootstrap validates and starts the cached `last-good` slot, waits for the controller, and then immediately attempts an update. After startup processing finishes—including the cold-start update on a fresh volume or the immediate update on a restart—the hourly timer starts. The first scheduled update therefore runs one hour after that processing completes. Each candidate is written to the inactive generation, validated, and atomically selected before Mihomo reloads it through the native `PUT /configs` API.
`/data/last-good` atomically points to one of two generation slots. On restart, a valid cached slot starts first and bootstrap immediately attempts an update. Download, HTTP, YAML, generation, Mihomo validation, publication, or reload failures leave the previous slot active. Successful updates use Mihomo's native `PUT /configs` API without replacing the foreground process.
Download, HTTP, YAML, generation, or Mihomo validation failures reject the candidate and keep the running `last-good`. A reload failure restores the prior pointer and reloads the prior configuration. A storage failure, or failure to persist/reload that rollback, stops Mihomo instead of claiming an unsafe recovery; Compose's restart policy then retries startup from whatever valid `last-good` remains.
Trigger the same update path immediately for tests or operations:
Updates run hourly from container start. Trigger the same update path immediately for tests or operations:
```sh
docker compose kill --signal HUP mihomo
docker kill --signal HUP mohomo-docker
```
The `bootstrap candidate` subcommand remains available for an isolated one-shot candidate pipeline check; a running service should use `SIGHUP` so the result is hot-reloaded.
## Troubleshooting
Start with service state and bounded logs:
```sh
docker compose ps
docker compose logs --tail=200 mihomo
curl --fail http://127.0.0.1:9090/version
docker compose exec mihomo readlink /data/last-good
docker compose exec mihomo /usr/local/bin/mihomo -t -d /data/last-good -f /data/last-good/config.yaml
```
Do not print `/run/secrets/subscription` or `/data/last-good/subscription.yaml` while collecting diagnostics.
- `cold-start candidate failed`: there is no valid cache and the secret, endpoint, response, or generated configuration was rejected. Confirm the secret file contains one reachable absolute HTTP(S) URL and that the response is a single Mihomo/Clash YAML document with a non-empty `proxies` list.
- `update rejected; keeping last-good`: the service remains available on the old configuration. Fix the subscription response or connectivity, then send `SIGHUP` to retry.
- `reload rejected; restored and reloaded last-good`: the new candidate did not load, and bootstrap restored the previous configuration. Inspect the preceding error without exposing the subscription.
- `fatal update stopped Mihomo`: persistence or rollback could not be guaranteed. Check free space, ownership, and write access for the `/data` volume before relying on automatic restart.
- Controller works but the UI does not: use the trailing-slash URL `/ui/` and confirm the 9090 mapping with `docker compose port mihomo 9090`. A remote browser cannot use the default loopback binding; change `CONTROLLER_BIND` only after adding an appropriate network boundary.
## Runtime assets
The image pins and SHA-256 verifies Mihomo `v1.19.30`, MetaCubeXD `v1.273.0`, and ACL4SSR commit `6e27259b8625e360699c014f98f978ee7408c644`. Rules and UI files are local to the image; runtime does not call an online converter or rule provider.
The container runs as an unprivileged user with all capabilities dropped, a read-only root filesystem, and only `/data` writable. Do not publish a derivative image without respecting the upstream Mihomo, MetaCubeXD, and ACL4SSR licenses.
## CI image publishing
GitHub Actions publishes to `ghcr.io/<github.repository>`. Gitea Actions publishes the same tested image to `git.ipao.vip/rogee/mohomo-docker` with `latest` and `sha-<full-commit>` tags. The workflows are independent and do not share registry credentials or provider contexts.
Before enabling Gitea publishing, add a repository Actions secret named `REGISTRY_TOKEN`. It must be a Gitea token whose owner can push packages for `rogee` and link the resulting container package to `rogee/mohomo-docker`. Keep the token out of files and logs, and rotate it in Gitea without changing the workflow.
Gitea publishes only after tests and the container smoke test pass on a `main` push or manual workflow dispatch. Pull requests run those validations but skip secret use, registry login, image push, and package linking. A failed link check leaves the pushed image intact; fix the token permissions and rerun the workflow to retry the idempotent link step.
## Local acceptance
```sh
./tests/container-smoke.sh
```
This single command builds the image and uses an isolated local provider with sanitized fake nodes and a fake query token. It covers cold-start failure, warm recovery, immediate and `SIGHUP` updates, invalid-candidate retention, restart recovery, the 7890/9090 boundary, ExternalUI loading, proxy-group reads, and one node switch. It never uses a real subscription or an online rule conversion service. The image build may still need network access to download the pinned official artifacts whose SHA-256 values are verified.
For the complete developer check set, run:
## Verification
```sh
./scripts/test.sh
@@ -108,4 +53,4 @@ go mod verify
git diff --check
```
All test fixtures use only sanitized fake values.
Tests use only local fake subscription URLs and fake node data.
+14 -60
View File
@@ -1,78 +1,32 @@
#!/bin/sh
set -eu
github_workflow=.github/workflows/docker.yml
gitea_workflow=.gitea/workflows/docker.yml
test -f "$github_workflow" || {
echo "missing GHCR workflow: $github_workflow" >&2
exit 1
}
test -f "$gitea_workflow" || {
echo "missing Gitea workflow: $gitea_workflow" >&2
workflow=.github/workflows/docker.yml
test -f "$workflow" || {
echo "missing GHCR workflow: $workflow" >&2
exit 1
}
for workflow in "$github_workflow" "$gitea_workflow"; do
grep -F ' push:' "$workflow" >/dev/null
grep -F ' pull_request:' "$workflow" >/dev/null
grep -F ' workflow_dispatch:' "$workflow" >/dev/null
done
gitea_trigger_count=$(awk '/^on:/ { in_on = 1; next } in_on && /^[^ ]/ { exit } in_on && /^ [a-z_]+:/ { count++ } END { print count + 0 }' "$gitea_workflow")
if [ "$gitea_trigger_count" -ne 3 ] || [ "$(grep -Fc ' - main' "$gitea_workflow")" -ne 2 ]; then
echo "Gitea workflow must only run for main pushes, main pull requests, and manual dispatches" >&2
exit 1
fi
grep -F 'packages: write' "$github_workflow" >/dev/null
grep -F 'packages: write' "$workflow" >/dev/null
# Match the GitHub expression literally.
# shellcheck disable=SC2016
grep -F 'ghcr.io/${{ github.repository }}' "$github_workflow" >/dev/null
grep -F 'platforms: linux/amd64' "$github_workflow" >/dev/null
grep -F 'needs: test' "$github_workflow" >/dev/null
grep -F 'run: ./tests/container-smoke.sh' "$github_workflow" >/dev/null
grep -F 'cache-to: type=gha,mode=max,ignore-error=true' "$github_workflow" >/dev/null
grep -F 'ghcr.io/${{ github.repository }}' "$workflow" >/dev/null
grep -F 'platforms: linux/amd64' "$workflow" >/dev/null
grep -F 'needs: test' "$workflow" >/dev/null
grep -F 'run: ./tests/container-smoke.sh' "$workflow" >/dev/null
grep -F 'cache-to: type=gha,mode=max,ignore-error=true' "$workflow" >/dev/null
# Match the GitHub expression literally.
# shellcheck disable=SC2016
grep -F 'push: ${{ github.event_name != '\''pull_request'\'' }}' "$github_workflow" >/dev/null
grep -F 'push: ${{ github.event_name != '\''pull_request'\'' }}' "$workflow" >/dev/null
grep -F 'run: ./scripts/test.sh' "$gitea_workflow" >/dev/null
grep -F 'run: ./tests/container-smoke.sh' "$gitea_workflow" >/dev/null
grep -F 'git.ipao.vip/rogee/mohomo-docker' "$gitea_workflow" >/dev/null
# Match Gitea expressions literally.
# shellcheck disable=SC2016
grep -F '${{ secrets.REGISTRY_TOKEN }}' "$gitea_workflow" >/dev/null
# shellcheck disable=SC2016
grep -F 'sha-${{ gitea.sha }}' "$gitea_workflow" >/dev/null
grep -F 'docker login git.ipao.vip' "$gitea_workflow" >/dev/null
grep -F 'docker push "$IMAGE_NAME:latest"' "$gitea_workflow" >/dev/null
grep -F 'api/v1/packages/rogee/container/mohomo-docker' "$gitea_workflow" >/dev/null
grep -F '/link/mohomo-docker' "$gitea_workflow" >/dev/null
publish_condition="if: gitea.event_name != 'pull_request'"
if [ "$(grep -Fc "$publish_condition" "$gitea_workflow")" -ne 3 ]; then
echo "every Gitea publishing step must be disabled for pull requests" >&2
exit 1
fi
if grep -Ei 'ghcr\.io|github\.|GITHUB_TOKEN|packages: write|docker/(login|build-push)-action' "$gitea_workflow" >/dev/null; then
echo "Gitea workflow must not depend on GitHub publishing" >&2
exit 1
fi
if grep -Ei 'gitea\.|REGISTRY_TOKEN|git\.ipao\.vip' "$github_workflow" >/dev/null; then
echo "GitHub workflow must not depend on Gitea publishing" >&2
exit 1
fi
if grep -Ei 'arm64|setup-qemu' "$github_workflow" >/dev/null; then
if grep -Ei 'arm64|setup-qemu' "$workflow" >/dev/null; then
echo "workflow must build linux/amd64 only and must not configure QEMU" >&2
exit 1
fi
uses_count=$(grep -Ehc '^[[:space:]]+uses:' "$github_workflow" "$gitea_workflow" | awk '{ total += $1 } END { print total }')
pinned_count=$(grep -Ehc '^[[:space:]]+uses: [^ ]+@[0-9a-f]{40}([[:space:]]|$)' "$github_workflow" "$gitea_workflow" | awk '{ total += $1 } END { print total }')
uses_count=$(grep -Ec '^[[:space:]]+uses:' "$workflow")
pinned_count=$(grep -Ec '^[[:space:]]+uses: [^ ]+@[0-9a-f]{40}([[:space:]]|$)' "$workflow")
if [ "$uses_count" -eq 0 ] || [ "$uses_count" -ne "$pinned_count" ]; then
echo "every action must be pinned to a full commit SHA" >&2
echo "every GitHub Action must be pinned to a full commit SHA" >&2
exit 1
fi