From bed8ac9fdf3caa75ba12f23876d2ac59393aac10 Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:01:36 +0800 Subject: [PATCH] fix: bind physical SIMs and validate multi-arch images --- .gitattributes | 1 + .github/workflows/docker.yml | 43 ++++++++++++++++ .github/workflows/release.yml | 9 ++-- Dockerfile | 13 +++-- docker-compose.yml | 19 +++---- scripts/docker-entrypoint.sh | 11 ++++ .../components/proxy/DeviceBindingsDialog.tsx | 51 ++++++++++++------- web/src/components/proxy/UpstreamSection.tsx | 8 +-- web/src/lib/i18n-en.ts | 16 ++++++ web/src/pages/ProxyPage.tsx | 2 +- 10 files changed, 135 insertions(+), 38 deletions(-) create mode 100644 .gitattributes create mode 100644 scripts/docker-entrypoint.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index aa99edf..7805117 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -15,7 +15,50 @@ env: IMAGE_NAME: ${{ github.repository }} jobs: + smoke: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + arch: amd64 + - platform: linux/arm64 + arch: arm64 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build ${{ matrix.platform }} smoke image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: ${{ matrix.platform }} + load: true + push: false + tags: vocat-smoke:${{ matrix.arch }} + build-args: | + VERSION=0.0.0-smoke + BUILD_TIME=${{ github.event.repository.updated_at }} + cache-from: type=gha + + - name: Verify ${{ matrix.platform }} runtime and smart-card stack + run: | + docker run --rm --platform '${{ matrix.platform }}' \ + vocat-smoke:${{ matrix.arch }} version + docker run --rm --platform '${{ matrix.platform }}' \ + --entrypoint /bin/sh vocat-smoke:${{ matrix.arch }} -c \ + 'command -v pcscd && test -d /usr/lib/pcsc/drivers' + build-and-push: + needs: smoke runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a8149e..16da157 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,6 +93,9 @@ jobs: with: name: web-dist path: web/dist + - name: Set up QEMU for ARM64 runtime smoke test + if: matrix.goarch == 'arm64' + uses: docker/setup-qemu-action@v3 - name: Build ${{ matrix.target }} env: GOOS: linux @@ -115,9 +118,9 @@ jobs: readelf -l "$OUTPUT" >&2 exit 1 fi - if [ "${{ matrix.goarch }}" = "amd64" ]; then - "$OUTPUT" version - fi + case "${{ matrix.goarch }}" in + amd64|arm64) "$OUTPUT" version ;; + esac - name: Upload ${{ matrix.target }} uses: actions/upload-artifact@v4 with: diff --git a/Dockerfile b/Dockerfile index a83ee73..543dc8a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,7 +36,7 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build \ # ---- Stage 3: minimal runtime ---- FROM alpine:3.20 -RUN apk add --no-cache ca-certificates tzdata && \ +RUN apk add --no-cache ca-certificates ccid pcsc-lite tzdata && \ addgroup -S -g 1000 vocat && \ adduser -S -D -H -u 1000 -G vocat vocat @@ -44,14 +44,19 @@ RUN mkdir -p /opt/vocat/bin /opt/vocat/data && \ chown -R vocat:vocat /opt/vocat COPY --from=go-builder /out/vocat /opt/vocat/bin/vocat +COPY scripts/docker-entrypoint.sh /usr/local/bin/vocat-entrypoint # Symlink into /usr/local/bin so `docker exec vocat ...` finds it via $PATH. -RUN ln -s /opt/vocat/bin/vocat /usr/local/bin/vocat +RUN ln -s /opt/vocat/bin/vocat /usr/local/bin/vocat && \ + chmod 0755 /usr/local/bin/vocat-entrypoint -USER vocat +# Hardware access and the bundled pcscd daemon require root inside the +# container. The container already needs host networking and privileged device +# access for modem, QMI, IPsec, and hot-plug support. +USER root VOLUME ["/opt/vocat/data"] EXPOSE 7575 ENV VOCAT_ADDR=0.0.0.0:7575 \ VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db -ENTRYPOINT ["/opt/vocat/bin/vocat"] +ENTRYPOINT ["/usr/local/bin/vocat-entrypoint"] diff --git a/docker-compose.yml b/docker-compose.yml index 1484e02..c73e21d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,11 +38,11 @@ services: # reachable on the host IP without explicit mapping. network_mode: host - # VoWiFi / eSIM / IMS paths need raw sockets (IPsec, netlink). The systemd - # unit grants CAP_NET_ADMIN + CAP_NET_RAW; mirror that here. - cap_add: - - NET_ADMIN - - NET_RAW + # Modem/QMI/USB-reader hot-plug uses dynamic character devices. Privileged + # mode mirrors the documented hardware-access docker run command and also + # supplies the raw-socket/netlink permissions needed by VoWiFi/IPsec. + privileged: true + user: "0:0" environment: # Marks the process as containerized: the web UI then advertises @@ -52,11 +52,12 @@ services: # credentials are stored only in SQLite and are not process environment. volumes: - # SQLite database + persistent state. Named volume (not a bind mount) - # because the container runs as uid 1000 (vocat) while a bind-mounted - # host dir would be root-owned and unwritable. Docker gives the named - # volume the image's uid 1000 ownership automatically. + # SQLite database + persistent state. - vocat-data:/opt/vocat/data + # Required for modem, MHI/WWAN and PC/SC USB-reader discovery, including + # devices added after the container starts. + - /dev:/dev + - /sys:/sys:ro volumes: vocat-data: diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh new file mode 100644 index 0000000..a3d2ec5 --- /dev/null +++ b/scripts/docker-entrypoint.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu + +# pcscd daemonizes after startup. Keep failure non-fatal so modem-only +# deployments remain usable and the UI can report a reader diagnostic. +if [ "$(id -u)" = "0" ] && command -v pcscd >/dev/null 2>&1; then + mkdir -p /run/pcscd + pcscd || echo "warning: pcscd failed to start; USB SIM readers may be unavailable" >&2 +fi + +exec /opt/vocat/bin/vocat "$@" diff --git a/web/src/components/proxy/DeviceBindingsDialog.tsx b/web/src/components/proxy/DeviceBindingsDialog.tsx index 2d712b5..b21f7cb 100644 --- a/web/src/components/proxy/DeviceBindingsDialog.tsx +++ b/web/src/components/proxy/DeviceBindingsDialog.tsx @@ -1,8 +1,8 @@ import { AddRegular, DeleteRegular } from "@fluentui/react-icons"; import { useEffect, useMemo, useState } from "react"; -import { api, apiMessage } from "../../api"; +import { api } from "../../api"; import type { DeviceListItem, DeviceProxyBinding, EsimOverview, ProfileProxyCandidate, UpstreamProxy } from "../../types"; -import { Button, EmptyState, Modal, Tag, message } from "../ui"; +import { Button, EmptyState, Modal, Tag } from "../ui"; import { useI18n } from "../../lib/i18n"; export interface DeviceBindingsDialogProps { @@ -29,7 +29,10 @@ export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) { const [candidates, setCandidates] = useState([]); const [selected, setSelected] = useState([]); const proxyName = proxy?.name || proxy?.id || ""; - const deviceKey = devices.map((device) => device.id).sort().join("|"); + const deviceKey = devices + .map((device) => `${device.id}:${String(device.modem?.iccid || "").trim()}`) + .sort() + .join("|"); const current = useMemo( () => bindings.filter((item) => item.upstreamProxyId === proxy?.id), [bindings, proxy?.id], @@ -50,13 +53,29 @@ export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) { let active = true; setLoadingProfiles(true); Promise.allSettled(devices.map(async (device) => { - const data = await api(`/devices/${encodeURIComponent(device.id)}/esim`); - return (data.profiles || []).flatMap((group) => (group.profiles || []).map((profile) => ({ - deviceId: device.id, - iccid: String(profile.iccid || "").trim(), - profileName: profileLabel(profile), - stateText: profile.stateText, - }))).filter((profile) => profile.iccid); + const currentICCID = String(device.modem?.iccid || "").trim(); + let installed: ProfileProxyCandidate[] = []; + try { + const data = await api(`/devices/${encodeURIComponent(device.id)}/esim`); + installed = (data.profiles || []).flatMap((group) => (group.profiles || []).map((profile) => ({ + deviceId: device.id, + iccid: String(profile.iccid || "").trim(), + profileName: profileLabel(profile), + stateText: profile.stateText, + }))).filter((profile) => profile.iccid); + } catch { + // A traditional SIM and some readers do not expose an eSIM profile + // inventory. Their live ICCID is still a valid VoWiFi route key. + } + if (currentICCID && !installed.some((profile) => profile.iccid === currentICCID)) { + installed.push({ + deviceId: device.id, + iccid: currentICCID, + profileName: t("当前 SIM 卡"), + stateText: t("当前使用中"), + }); + } + return installed; })).then((results) => { if (!active) return; const unique = new Map(); @@ -65,8 +84,6 @@ export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) { for (const profile of result.value) if (!unique.has(profile.iccid)) unique.set(profile.iccid, profile); } setCandidates(Array.from(unique.values()).sort((a, b) => a.deviceId.localeCompare(b.deviceId) || a.profileName.localeCompare(b.profileName))); - }).catch((error) => { - if (active) message.error(apiMessage(error) || t("读取 eSIM Profile 失败")); }).finally(() => { if (active) setLoadingProfiles(false); }); @@ -94,13 +111,13 @@ export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) { const toggleAll = () => setSelected(allSelected ? [] : selectable); return ( - +
- {t("VoWiFi 会按当前 ICCID 选择代理。同一 ICCID 只能绑定一个代理,一个代理可以绑定多台设备上的多个 Profile。")} + {t("VoWiFi 会按当前 ICCID 选择代理。实体 SIM 和 eSIM Profile 都可以绑定;同一 ICCID 只能绑定一个代理。")}
-
{adding ? t("从设备已安装的 eSIM Profile 中选择") : `${current.length} ${t("个 Profile")}`}
+
{adding ? t("从当前 SIM 卡和已安装的 eSIM Profile 中选择") : `${current.length} ${t("个 SIM / Profile")}`}
{adding ? ( @@ -130,7 +147,7 @@ export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) { {t("设备 ID")} ICCID - {t("Profile 名称")} + {t("SIM / Profile")} {adding ? {t("状态")} : null} @@ -155,7 +172,7 @@ export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) { {loadingProfiles ?
{t("读取 Profile 中...")}
: null} - {!loadingProfiles && rows.length === 0 ? : null} + {!loadingProfiles && rows.length === 0 ? : null}
diff --git a/web/src/components/proxy/UpstreamSection.tsx b/web/src/components/proxy/UpstreamSection.tsx index 836f904..f6a4886 100644 --- a/web/src/components/proxy/UpstreamSection.tsx +++ b/web/src/components/proxy/UpstreamSection.tsx @@ -38,7 +38,7 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet {t("地址")} {t("鉴权")} {t("状态")} - {t("Profile 绑定")} + {t("SIM / Profile 绑定")} {t("操作")} @@ -53,12 +53,12 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
- {row.bindingCount} {t("个 Profile")} + {row.bindingCount} {t("个 SIM / Profile")}
- +
@@ -72,7 +72,7 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
{t("暂无上游代理")}
-
{t("点击“新增代理”创建 SOCKS5 上游代理,再按 ICCID 绑定需要使用它的 eSIM Profile;未绑定 Profile 默认直连。")}
+
{t("点击“新增代理”创建 SOCKS5 上游代理,再按 ICCID 绑定实体 SIM 或 eSIM Profile;未绑定的卡默认直连。")}
) : null} {loading ?
{t("加载中...")}
: null} diff --git a/web/src/lib/i18n-en.ts b/web/src/lib/i18n-en.ts index b3547a0..ddbc7ae 100644 --- a/web/src/lib/i18n-en.ts +++ b/web/src/lib/i18n-en.ts @@ -87,6 +87,22 @@ export const EN_DICT: Record = { "设备绑定": "Device Bindings", "Profile 绑定": "Profile Bindings", "添加 Profile 绑定": "Add Profile Bindings", + "SIM / Profile 绑定": "SIM / Profile Bindings", + "添加 SIM / Profile 绑定": "Add SIM / Profile Bindings", + "VoWiFi 会按当前 ICCID 选择代理。实体 SIM 和 eSIM Profile 都可以绑定;同一 ICCID 只能绑定一个代理。": + "VoWiFi selects its proxy by the active ICCID. Both physical SIMs and eSIM profiles can be bound, and each ICCID can use only one proxy.", + "从当前 SIM 卡和已安装的 eSIM Profile 中选择": "Select from the current SIM and installed eSIM profiles", + "个 SIM / Profile": "SIMs / profiles", + "当前 SIM 卡": "Current SIM", + "当前使用中": "Currently active", + "SIM / Profile": "SIM / Profile", + "没有可显示的 SIM / Profile": "No SIMs or profiles to display", + "尚未绑定 SIM / Profile": "No SIMs or profiles bound", + "请确认设备在线并已读取到 SIM 卡 ICCID。": "Make sure the device is online and its SIM ICCID has been read.", + "点击添加,从 SIM / Profile 列表中选择。": "Click Add and select from the SIM / profile list.", + "管理 VoWiFi 上游代理以及实体 SIM / eSIM Profile 绑定": "Manage VoWiFi upstream proxies and physical SIM / eSIM profile bindings", + "点击“新增代理”创建 SOCKS5 上游代理,再按 ICCID 绑定实体 SIM 或 eSIM Profile;未绑定的卡默认直连。": + "Create a SOCKS5 upstream proxy, then bind a physical SIM or eSIM profile by ICCID. Unbound SIMs use a direct connection.", "VoWiFi 会按当前 ICCID 选择代理。同一 ICCID 只能绑定一个代理,一个代理可以绑定多台设备上的多个 Profile。": "VoWiFi selects its proxy by the active ICCID. An ICCID can use only one proxy, while one proxy can serve profiles across multiple devices.", "从设备已安装的 eSIM Profile 中选择": "Select from eSIM profiles installed on the devices", diff --git a/web/src/pages/ProxyPage.tsx b/web/src/pages/ProxyPage.tsx index cc36914..15d591b 100644 --- a/web/src/pages/ProxyPage.tsx +++ b/web/src/pages/ProxyPage.tsx @@ -241,7 +241,7 @@ export default function ProxyPage() {
} onClick={() => openUpstreamDialog()}>{t("新增代理")}} />