mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-16 04:43:43 +08:00
fix: bind physical SIMs and validate multi-arch images
This commit is contained in:
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user