diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index 163f1484..84392834 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -427,6 +427,15 @@ lanes: runtimeImageRewrites: - source: fatedier/frpc:v0.68.1 target: 127.0.0.1:5000/hwlab/frpc:v0.68.1 + runtimeImageBuilds: + - id: moonbridge + kind: moonbridge + target: 127.0.0.1:5000/hwlab/moonbridge:1b99888d3dae + sourceRepo: https://github.com/ZhiYi-R/moon-bridge.git + sourceRef: 1b99888d3dae889b79ee602cb875c7907f7e76f2 + builderImage: golang:1.25-bookworm + goProxy: https://goproxy.cn,direct + dockerNetworkMode: host public: webUrl: https://hwlab.pikapython.com apiUrl: https://hwlab.pikapython.com @@ -628,6 +637,15 @@ lanes: runtimeImageRewrites: - source: fatedier/frpc:v0.68.1 target: 127.0.0.1:5000/hwlab/frpc:v0.68.1 + runtimeImageBuilds: + - id: moonbridge + kind: moonbridge + target: 127.0.0.1:5000/hwlab/moonbridge:1b99888d3dae + sourceRepo: https://github.com/ZhiYi-R/moon-bridge.git + sourceRef: 1b99888d3dae889b79ee602cb875c7907f7e76f2 + builderImage: golang:1.25-bookworm + goProxy: https://goproxy.cn,direct + dockerNetworkMode: host public: webUrl: https://hwlab.pikapython.com apiUrl: https://hwlab.pikapython.com @@ -795,9 +813,9 @@ networkProfiles: - 82.156.23.220 - 74.48.78.17 dockerBuildProxy: - http: http://sub2api-egress-proxy.platform-infra.svc.cluster.local:10808 - https: http://sub2api-egress-proxy.platform-infra.svc.cluster.local:10808 - all: http://sub2api-egress-proxy.platform-infra.svc.cluster.local:10808 + http: http://127.0.0.1:10808 + https: http://127.0.0.1:10808 + all: http://127.0.0.1:10808 noProxy: - localhost - 127.0.0.1 @@ -832,9 +850,9 @@ networkProfiles: - 82.156.23.220 - 74.48.78.17 dockerBuildProxy: - http: http://sub2api-egress-proxy.platform-infra.svc.cluster.local:10808 - https: http://sub2api-egress-proxy.platform-infra.svc.cluster.local:10808 - all: http://sub2api-egress-proxy.platform-infra.svc.cluster.local:10808 + http: http://127.0.0.1:10808 + https: http://127.0.0.1:10808 + all: http://127.0.0.1:10808 noProxy: - localhost - 127.0.0.1 diff --git a/config/platform-infra/sub2api.yaml b/config/platform-infra/sub2api.yaml index cee19342..b1c9b496 100644 --- a/config/platform-infra/sub2api.yaml +++ b/config/platform-infra/sub2api.yaml @@ -246,7 +246,7 @@ targets: image: ghcr.io/sagernet/sing-box:latest imagePullPolicy: IfNotPresent listenPort: 10808 - hostNetwork: false + hostNetwork: true sourceConfigRef: config/platform-infra/egress-proxy-sources.yaml#sources.master-shadowsocks applyToSub2Api: true applyToSentinel: true diff --git a/scripts/src/hwlab-node-lanes.ts b/scripts/src/hwlab-node-lanes.ts index 6a4e4a09..5a6953b0 100644 --- a/scripts/src/hwlab-node-lanes.ts +++ b/scripts/src/hwlab-node-lanes.ts @@ -254,6 +254,17 @@ export interface HwlabRuntimeImageRewriteSpec { readonly target: string; } +export interface HwlabRuntimeImageBuildSpec { + readonly id: string; + readonly kind: "moonbridge"; + readonly target: string; + readonly sourceRepo: string; + readonly sourceRef: string; + readonly builderImage: string; + readonly goProxy: string; + readonly dockerNetworkMode: "default" | "host"; +} + export interface HwlabRuntimePublicExposureFrpcProxySpec { readonly name: string; readonly localIP: string; @@ -346,6 +357,7 @@ export interface HwlabRuntimeLaneSpec { readonly publicExposure: HwlabRuntimePublicExposureSpec | null; readonly observability: HwlabRuntimeObservabilitySpec; readonly runtimeImageRewrites: readonly HwlabRuntimeImageRewriteSpec[]; + readonly runtimeImageBuilds: readonly HwlabRuntimeImageBuildSpec[]; readonly networkProfileId: string; readonly downloadProfileId: string; readonly networkProfile: HwlabNetworkProfileSpec; @@ -395,6 +407,7 @@ interface HwlabLaneConfig { readonly publicExposure: HwlabRuntimePublicExposureSpec | null; readonly observability: HwlabRuntimeObservabilitySpec; readonly runtimeImageRewrites: readonly HwlabRuntimeImageRewriteSpec[]; + readonly runtimeImageBuilds: readonly HwlabRuntimeImageBuildSpec[]; } interface HwlabNodeLaneConfig { @@ -421,6 +434,12 @@ function stringField(obj: Record, key: string, path: string): s return value; } +function enumStringField(obj: Record, key: string, path: string, allowed: readonly T[]): T { + const value = stringField(obj, key, path); + if (!allowed.includes(value as T)) throw new Error(`${path}.${key} must be one of ${allowed.join(", ")}`); + return value as T; +} + function numberField(obj: Record, key: string, path: string): number { const value = obj[key]; if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) throw new Error(`${path}.${key} must be a positive integer`); @@ -630,6 +649,7 @@ function laneConfig(id: HwlabRuntimeLane, raw: Record): HwlabLa publicExposure: publicExposureConfig(raw.publicExposure, `lanes.${id}.publicExposure`), observability: observabilityConfig(raw.observability, `lanes.${id}.observability`), runtimeImageRewrites: runtimeImageRewritesConfig(raw.runtimeImageRewrites, `lanes.${id}.runtimeImageRewrites`), + runtimeImageBuilds: runtimeImageBuildsConfig(raw.runtimeImageBuilds, `lanes.${id}.runtimeImageBuilds`), }; } @@ -1149,6 +1169,27 @@ function runtimeImageRewritesConfig(value: unknown, path: string): HwlabRuntimeI }); } +function runtimeImageBuildsConfig(value: unknown, path: string): HwlabRuntimeImageBuildSpec[] { + if (value === undefined) return []; + if (!Array.isArray(value)) throw new Error(`${path} must be an array`); + return value.map((item, index) => { + const itemPath = `${path}[${index}]`; + const raw = asRecord(item, itemPath); + const kind = stringField(raw, "kind", itemPath); + if (kind !== "moonbridge") throw new Error(`${itemPath}.kind must be moonbridge`); + return { + id: stringField(raw, "id", itemPath), + kind, + target: stringField(raw, "target", itemPath), + sourceRepo: stringField(raw, "sourceRepo", itemPath), + sourceRef: stringField(raw, "sourceRef", itemPath), + builderImage: stringField(raw, "builderImage", itemPath), + goProxy: stringField(raw, "goProxy", itemPath), + dockerNetworkMode: enumStringField(raw, "dockerNetworkMode", itemPath, ["default", "host"]), + }; + }); +} + function readHwlabNodeLaneConfig(): HwlabNodeLaneConfig { const path = rootPath(HWLAB_NODE_LANE_CONFIG_PATH); const raw = readFileSync(path, "utf8"); @@ -1273,6 +1314,7 @@ function buildRuntimeLaneSpec(config: HwlabLaneConfig): HwlabRuntimeLaneSpec { publicExposure: config.publicExposure, observability: config.observability, runtimeImageRewrites: config.runtimeImageRewrites, + runtimeImageBuilds: config.runtimeImageBuilds, networkProfileId: networkProfile.id, downloadProfileId: downloadProfile.id, networkProfile, diff --git a/scripts/src/hwlab-node/plan.ts b/scripts/src/hwlab-node/plan.ts index 251aaa3f..e8ff6124 100644 --- a/scripts/src/hwlab-node/plan.ts +++ b/scripts/src/hwlab-node/plan.ts @@ -153,6 +153,7 @@ export function nodeRuntimeExpected(spec: HwlabRuntimeLaneSpec): Record { - images.push({ id: `rewrite-${index + 1}`, target: rewrite.target, source: rewrite.source }); + images.push({ id: `rewrite-${index + 1}`, target: rewrite.target, source: rewrite.source, mode: "pull" }); + }); + spec.runtimeImageBuilds.forEach((build) => { + images.push({ + id: build.id, + target: build.target, + source: `${build.sourceRepo}#${build.sourceRef}`, + mode: "build-moonbridge", + sourceRepo: build.sourceRepo, + sourceRef: build.sourceRef, + builderImage: build.builderImage, + goProxy: build.goProxy, + dockerNetworkMode: build.dockerNetworkMode, + }); }); return images; } function nodeRuntimeImageCalls(spec: HwlabRuntimeLaneSpec, functionName: string): string { return nodeRuntimeImageDependencies(spec) - .map((image) => `${functionName} ${shellQuote(image.id)} ${shellQuote(image.target)} ${shellQuote(image.source)}`) + .map((image) => [ + functionName, + image.id, + image.target, + image.source, + image.mode, + image.sourceRepo ?? "", + image.sourceRef ?? "", + image.builderImage ?? "", + image.goProxy ?? "", + image.dockerNetworkMode ?? "", + ].map(shellQuote).join(" ")) .join("\n"); } @@ -845,7 +876,7 @@ export function nodeRuntimeBaseImageStatus(spec: HwlabRuntimeLaneSpec, timeoutSe const script = [ "set -eu", "check_image() {", - " id=\"$1\"; target=\"$2\"; source=\"$3\"", + " id=\"$1\"; target=\"$2\"; source=\"$3\"; mode=\"$4\"", " repo_tag=${target#*/}", " repo=${repo_tag%:*}", " tag=${repo_tag##*:}", @@ -856,12 +887,12 @@ export function nodeRuntimeBaseImageStatus(spec: HwlabRuntimeLaneSpec, timeoutSe " target_present=false", " if docker image inspect \"$target\" >/dev/null 2>&1; then target_present=true; fi", " source_present=false", - " if [ -n \"$source\" ] && docker image inspect \"$source\" >/dev/null 2>&1; then source_present=true; fi", + " if [ \"$mode\" = build-moonbridge ]; then source_present=true; elif [ -n \"$source\" ] && docker image inspect \"$source\" >/dev/null 2>&1; then source_present=true; fi", " source_configured=false", " if [ -n \"$source\" ]; then source_configured=true; fi", - " python3 - \"$id\" \"$target\" \"$source\" \"$source_configured\" \"$registry_url\" \"$registry_present\" \"$target_present\" \"$source_present\" <<'PY'", + " python3 - \"$id\" \"$target\" \"$source\" \"$mode\" \"$source_configured\" \"$registry_url\" \"$registry_present\" \"$target_present\" \"$source_present\" <<'PY'", "import json, sys", - "keys=['id','target','source','sourceConfigured','registryUrl','registryTagPresent','targetImagePresent','sourceImagePresent']", + "keys=['id','target','source','mode','sourceConfigured','registryUrl','registryTagPresent','targetImagePresent','sourceImagePresent']", "data=dict(zip(keys, sys.argv[1:]))", "for key in ['sourceConfigured','registryTagPresent','targetImagePresent','sourceImagePresent']:", " data[key] = data[key] == 'true'", @@ -893,9 +924,57 @@ export function ensureNodeBaseImage(spec: HwlabRuntimeLaneSpec, dryRun: boolean, "set -eu", `dry_run=${shellQuote(dryRun ? "true" : "false")}`, `pull_retries=${shellQuote(String(Math.max(1, spec.downloadProfile.docker.pullRetries)))}`, + `build_http_proxy=${shellQuote(spec.networkProfile.dockerBuildProxy.http)}`, + `build_https_proxy=${shellQuote(spec.networkProfile.dockerBuildProxy.https)}`, + `build_all_proxy=${shellQuote(spec.networkProfile.dockerBuildProxy.all)}`, + `build_no_proxy=${shellQuote(spec.networkProfile.dockerBuildProxy.noProxy.join(","))}`, + "resolve_host_proxy_url() {", + " raw_url=\"$1\"", + " if [ -z \"$raw_url\" ]; then printf '\\n'; return 0; fi", + " info=$(python3 - \"$raw_url\" <<'PY'", + "from urllib.parse import urlparse", + "import sys", + "url = sys.argv[1]", + "parsed = urlparse(url)", + "host = parsed.hostname or ''", + "port = parsed.port", + "scheme = parsed.scheme or 'http'", + "parts = host.split('.')", + "if len(parts) >= 4 and parts[2] == 'svc':", + " print('\\t'.join(['service', scheme, parts[0], parts[1], str(port or 80)]))", + "else:", + " print('\\t'.join(['literal', url]))", + "PY", + " )", + " kind=$(printf '%s' \"$info\" | cut -f1)", + " service_cluster_ip() {", + " service_namespace=\"$1\"; service_name=\"$2\"", + " cluster_ip=\"\"", + " if command -v kubectl >/dev/null 2>&1; then", + " cluster_ip=$(kubectl -n \"$service_namespace\" get svc \"$service_name\" -o jsonpath='{.spec.clusterIP}' 2>/dev/null || true)", + " fi", + " if { [ -z \"$cluster_ip\" ] || [ \"$cluster_ip\" = None ]; } && command -v k3s >/dev/null 2>&1; then", + " cluster_ip=$(k3s kubectl -n \"$service_namespace\" get svc \"$service_name\" -o jsonpath='{.spec.clusterIP}' 2>/dev/null || true)", + " fi", + " printf '%s\\n' \"$cluster_ip\"", + " }", + " if [ \"$kind\" = service ]; then", + " scheme=$(printf '%s' \"$info\" | cut -f2)", + " service_name=$(printf '%s' \"$info\" | cut -f3)", + " service_namespace=$(printf '%s' \"$info\" | cut -f4)", + " service_port=$(printf '%s' \"$info\" | cut -f5)", + " cluster_ip=$(service_cluster_ip \"$service_namespace\" \"$service_name\")", + " if [ -n \"$cluster_ip\" ] && [ \"$cluster_ip\" != None ]; then printf '%s://%s:%s\\n' \"$scheme\" \"$cluster_ip\" \"$service_port\"; else printf '%s\\n' \"$raw_url\"; fi", + " else", + " printf '%s\\n' \"$raw_url\"", + " fi", + "}", + "build_http_proxy=$(resolve_host_proxy_url \"$build_http_proxy\")", + "build_https_proxy=$(resolve_host_proxy_url \"$build_https_proxy\")", + "build_all_proxy=$(resolve_host_proxy_url \"$build_all_proxy\")", "failed=false", "ensure_image() {", - " id=\"$1\"; target=\"$2\"; source=\"$3\"", + " id=\"$1\"; target=\"$2\"; source=\"$3\"; mode=\"$4\"; source_repo=\"$5\"; source_ref=\"$6\"; builder_image=\"$7\"; go_proxy=\"$8\"; docker_network_mode=\"$9\"", " repo_tag=${target#*/}", " repo=${repo_tag%:*}", " tag=${repo_tag##*:}", @@ -910,13 +989,47 @@ export function ensureNodeBaseImage(spec: HwlabRuntimeLaneSpec, dryRun: boolean, " if [ \"$present\" = false ]; then", " action=seed", " if [ \"$dry_run\" = false ]; then", + " if [ \"$mode\" = build-moonbridge ]; then", + " action=build", + " source_present_before=build-source", + " tmpdir=$(mktemp -d /tmp/hwlab-node-runtime-image-$id.XXXXXX)", + " dockerfile=\"$tmpdir/Dockerfile\"", + " cat > \"$dockerfile\" <<'DOCKERFILE'", + "ARG BUILDER_IMAGE", + "FROM ${BUILDER_IMAGE} AS builder", + "ARG MOONBRIDGE_REPO", + "ARG MOONBRIDGE_REF", + "ARG GOPROXY_VALUE", + "ENV GOPROXY=${GOPROXY_VALUE}", + "WORKDIR /src", + "RUN git clone --filter=blob:none \"${MOONBRIDGE_REPO}\" moon-bridge", + "WORKDIR /src/moon-bridge", + "RUN git checkout \"${MOONBRIDGE_REF}\" \\", + " && go mod download \\", + " && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags=\"-s -w\" -o /out/moonbridge ./cmd/moonbridge", + "FROM scratch", + "WORKDIR /app", + "COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt", + "COPY --from=builder /out/moonbridge /app/moonbridge", + "EXPOSE 4001", + "USER 65532:65532", + "ENTRYPOINT [\"/app/moonbridge\"]", + "DOCKERFILE", + " if env HTTP_PROXY=\"$build_http_proxy\" HTTPS_PROXY=\"$build_https_proxy\" ALL_PROXY=\"$build_all_proxy\" NO_PROXY=\"$build_no_proxy\" http_proxy=\"$build_http_proxy\" https_proxy=\"$build_https_proxy\" all_proxy=\"$build_all_proxy\" no_proxy=\"$build_no_proxy\" docker build --network \"$docker_network_mode\" --build-arg BUILDER_IMAGE=\"$builder_image\" --build-arg MOONBRIDGE_REPO=\"$source_repo\" --build-arg MOONBRIDGE_REF=\"$source_ref\" --build-arg GOPROXY_VALUE=\"$go_proxy\" --build-arg HTTP_PROXY=\"$build_http_proxy\" --build-arg HTTPS_PROXY=\"$build_https_proxy\" --build-arg ALL_PROXY=\"$build_all_proxy\" --build-arg NO_PROXY=\"$build_no_proxy\" -t \"$target\" -f \"$dockerfile\" \"$tmpdir\" >/tmp/hwlab-node-runtime-image-$id-build.out 2>&1; then", + " docker push \"$target\" >/tmp/hwlab-node-runtime-image-$id-push.out 2>&1 || { cat /tmp/hwlab-node-runtime-image-$id-push.out >&2 2>/dev/null || true; failed=true; }", + " else", + " cat /tmp/hwlab-node-runtime-image-$id-build.out >&2 2>/dev/null || true", + " failed=true", + " fi", + " rm -rf \"$tmpdir\"", + " else", " source_present_before=true", " if ! docker image inspect \"$source\" >/dev/null 2>&1; then", " source_present_before=false", " attempt=1", " while [ \"$attempt\" -le \"$pull_retries\" ]; do", " pull_attempts=$attempt", - " if docker pull \"$source\" >/tmp/hwlab-node-runtime-image-$id-pull.out 2>&1; then pulled_source=true; break; fi", + " if env HTTP_PROXY=\"$build_http_proxy\" HTTPS_PROXY=\"$build_https_proxy\" ALL_PROXY=\"$build_all_proxy\" NO_PROXY=\"$build_no_proxy\" http_proxy=\"$build_http_proxy\" https_proxy=\"$build_https_proxy\" all_proxy=\"$build_all_proxy\" no_proxy=\"$build_no_proxy\" docker pull \"$source\" >/tmp/hwlab-node-runtime-image-$id-pull.out 2>&1; then pulled_source=true; break; fi", " attempt=$((attempt + 1))", " sleep 2", " done", @@ -930,13 +1043,14 @@ export function ensureNodeBaseImage(spec: HwlabRuntimeLaneSpec, dryRun: boolean, " failed=true", " fi", " fi", + " fi", " fi", " fi", " after=false", " if curl -fsS \"$registry_url\" 2>/dev/null | grep -q '\"'\"$tag\"'\"'; then after=true; fi", - " python3 - \"$id\" \"$target\" \"$source\" \"$dry_run\" \"$present\" \"$after\" \"$action\" \"$pull_retries\" \"$source_present_before\" \"$pulled_source\" \"$pull_attempts\" <<'PY'", + " python3 - \"$id\" \"$target\" \"$source\" \"$mode\" \"$dry_run\" \"$present\" \"$after\" \"$action\" \"$pull_retries\" \"$source_present_before\" \"$pulled_source\" \"$pull_attempts\" <<'PY'", "import json, sys", - "keys=['id','target','source','dryRun','presentBefore','presentAfter','action','pullRetries','sourcePresentBefore','pulledSource','pullAttempts']", + "keys=['id','target','source','mode','dryRun','presentBefore','presentAfter','action','pullRetries','sourcePresentBefore','pulledSource','pullAttempts']", "data=dict(zip(keys, sys.argv[1:]))", "for key in ['dryRun','presentBefore','presentAfter','pulledSource']:", " data[key] = data[key] == 'true'", @@ -949,7 +1063,10 @@ export function ensureNodeBaseImage(spec: HwlabRuntimeLaneSpec, dryRun: boolean, nodeRuntimeImageCalls(spec, "ensure_image"), "if [ \"$failed\" = true ]; then exit 1; fi", ].join("\n"); - const result = runNodeHostScript(spec, script, Math.min(timeoutSeconds, 300)); + const hasBuild = nodeRuntimeImageDependencies(spec).some((image) => image.mode === "build-moonbridge"); + const result = hasBuild && !dryRun + ? runNodeHostScriptAsync(spec, script, Math.min(timeoutSeconds, 600), "runtime-image-build") + : runNodeHostScript(spec, script, Math.min(timeoutSeconds, 300)); const imageRows = parseNodeRuntimeImageRows(statusText(result)); const base = imageRows.find((image) => image.id === "base") ?? {}; return {