fix: bind physical SIMs and validate multi-arch images

This commit is contained in:
MengMengCode
2026-08-13 11:01:36 +08:00
parent 1100f20dc5
commit bed8ac9fdf
10 changed files with 135 additions and 38 deletions
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+43
View File
@@ -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
+6 -3
View File
@@ -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:
+9 -4
View File
@@ -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 <ctr> 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"]
+10 -9
View File
@@ -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:
+11
View File
@@ -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 "$@"
@@ -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<ProfileProxyCandidate[]>([]);
const [selected, setSelected] = useState<string[]>([]);
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<EsimOverview>(`/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<EsimOverview>(`/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<string, ProfileProxyCandidate>();
@@ -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 (
<Modal open={open} onClose={onClose} title={`${adding ? t("添加 Profile 绑定") : t("Profile 绑定")}${proxyName}`} width="max-w-5xl">
<Modal open={open} onClose={onClose} title={`${adding ? t("添加 SIM / Profile 绑定") : t("SIM / Profile 绑定")}${proxyName}`} width="max-w-5xl">
<div className="space-y-4 pb-2">
<div className="rounded-lg border border-sky-200/70 bg-sky-50 px-3 py-2 text-xs text-sky-800 dark:border-sky-800/50 dark:bg-sky-900/20 dark:text-sky-200">
{t("VoWiFi 会按当前 ICCID 选择代理。同一 ICCID 只能绑定一个代理,一个代理可以绑定多台设备上的多个 Profile。")}
{t("VoWiFi 会按当前 ICCID 选择代理。实体 SIM 和 eSIM Profile 都可以绑定;同一 ICCID 只能绑定一个代理。")}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs text-gray-500">{adding ? t("从设备已安装的 eSIM Profile 中选择") : `${current.length} ${t("个 Profile")}`}</div>
<div className="text-xs text-gray-500">{adding ? t("从当前 SIM 卡和已安装的 eSIM Profile 中选择") : `${current.length} ${t("个 SIM / Profile")}`}</div>
<div className="flex gap-2">
{adding ? (
<Button size="small" onClick={() => { setAdding(false); setSelected([]); }}>{t("返回绑定列表")}</Button>
@@ -130,7 +147,7 @@ export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) {
<th className="w-12 px-4 py-3"><input type="checkbox" checked={allSelected} onChange={toggleAll} disabled={selectable.length === 0 || busy} aria-label={t("全选")} /></th>
<th className="px-4 py-3">{t("设备 ID")}</th>
<th className="px-4 py-3">ICCID</th>
<th className="px-4 py-3">{t("Profile 名称")}</th>
<th className="px-4 py-3">{t("SIM / Profile")}</th>
{adding ? <th className="px-4 py-3">{t("状态")}</th> : null}
</tr>
</thead>
@@ -155,7 +172,7 @@ export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) {
</tbody>
</table>
{loadingProfiles ? <div className="px-6 py-12 text-center text-sm text-gray-400">{t("读取 Profile 中...")}</div> : null}
{!loadingProfiles && rows.length === 0 ? <EmptyState title={adding ? t("没有可显示的 eSIM Profile") : t("尚未绑定 Profile")} subtitle={adding ? t("请确认设备在线且支持 eSIM Profile 列表读取。") : t("点击添加,从设备 Profile 列表中选择。")}/>: null}
{!loadingProfiles && rows.length === 0 ? <EmptyState title={adding ? t("没有可显示的 SIM / Profile") : t("尚未绑定 SIM / Profile")} subtitle={adding ? t("请确认设备在线并已读取到 SIM 卡 ICCID。") : t("点击添加,从 SIM / Profile 列表中选择。")}/>: null}
</div>
</div>
</Modal>
+4 -4
View File
@@ -38,7 +38,7 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
<th className="px-4 py-3">{t("地址")}</th>
<th className="px-4 py-3">{t("鉴权")}</th>
<th className="px-4 py-3">{t("状态")}</th>
<th className="px-4 py-3">{t("Profile 绑定")}</th>
<th className="px-4 py-3">{t("SIM / Profile 绑定")}</th>
<th className="px-4 py-3 text-right">{t("操作")}</th>
</tr>
</thead>
@@ -53,12 +53,12 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
<td className="px-4 py-3">
<div className="inline-flex items-center gap-1 rounded border border-indigo-200/60 bg-indigo-50 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:border-indigo-800/40 dark:bg-indigo-900/20 dark:text-indigo-400">
<DesktopRegular className="text-[14px]" />
<span>{row.bindingCount} {t("个 Profile")}</span>
<span>{row.bindingCount} {t("个 SIM / Profile")}</span>
</div>
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button size="small" icon={<DesktopRegular />} onClick={() => onOpenBindings(row)}>{t("Profile 绑定")}</Button>
<Button size="small" icon={<DesktopRegular />} onClick={() => onOpenBindings(row)}>{t("SIM / Profile 绑定")}</Button>
<Button size="small" icon={<EditRegular />} onClick={() => onEdit(row)}>{t("编辑")}</Button>
<Button size="small" variant="danger" plain icon={<DeleteRegular />} onClick={() => onDelete(row)}>{t("删除")}</Button>
</div>
@@ -72,7 +72,7 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
<div className="flex flex-col items-center justify-center px-6 py-16 text-center text-gray-400">
<GlobeRegular className="mb-3 text-4xl" />
<div className="text-sm">{t("暂无上游代理")}</div>
<div className="mt-1 text-xs">{t("点击“新增代理”创建 SOCKS5 上游代理,再按 ICCID 绑定需要使用它的 eSIM Profile;未绑定 Profile 默认直连。")}</div>
<div className="mt-1 text-xs">{t("点击“新增代理”创建 SOCKS5 上游代理,再按 ICCID 绑定实体 SIM 或 eSIM Profile;未绑定的卡默认直连。")}</div>
</div>
) : null}
{loading ? <div className="px-6 py-16 text-center text-sm text-gray-400">{t("加载中...")}</div> : null}
+16
View File
@@ -87,6 +87,22 @@ export const EN_DICT: Record<string, string> = {
"设备绑定": "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",
+1 -1
View File
@@ -241,7 +241,7 @@ export default function ProxyPage() {
<div className="mx-auto max-w-7xl">
<PageHeader
title={t("代理管理")}
subtitle={t("管理 VoWiFi 上游代理 eSIM Profile 绑定")}
subtitle={t("管理 VoWiFi 上游代理以及实体 SIM / eSIM Profile 绑定")}
actions={<Button variant="primary" icon={<AddRegular />} onClick={() => openUpstreamDialog()}>{t("新增代理")}</Button>}
/>
<UpstreamSection