3 Commits
15 changed files with 301 additions and 46 deletions
+20 -7
View File
@@ -12,19 +12,32 @@ import (
)
func lockServerInstance(databasePath string) (*os.File, error) {
directory := filepath.Dir(databasePath)
if err := os.MkdirAll(directory, 0o755); err != nil {
return nil, fmt.Errorf("create data directory for instance lock: %w", err)
// The modem, PC/SC reader, XFRM policies and listener are host resources,
// not database resources. Lock per OS user so a diagnostic instance using a
// different VOCAT_DATABASE_PATH cannot silently steal the same AT port from
// the managed service. Prefer /run because systemd's PrivateTmp would
// otherwise hide the managed service's lock from a manually started process.
// The UID-specific directory still permits intentionally isolated users to
// operate independently; development hosts without writable /run fall back
// to TempDir.
uid := os.Geteuid()
directory := filepath.Join("/run", fmt.Sprintf("vocat-%d", uid))
if uid == 0 {
directory = "/run/vocat"
}
path := filepath.Join(directory, ".vocat.lock")
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err := os.MkdirAll(directory, 0o700); err != nil {
directory = os.TempDir()
}
path := filepath.Join(directory, "vocat-server.lock")
fd, err := unix.Open(path, unix.O_CREAT|unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0o600)
if err != nil {
return nil, fmt.Errorf("open server instance lock: %w", err)
}
if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
file := os.NewFile(uintptr(fd), path)
if err := unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB); err != nil {
_ = file.Close()
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
return nil, fmt.Errorf("another vocat server is already using database %s", databasePath)
return nil, errors.New("another vocat server already controls this host's modem resources")
}
return nil, fmt.Errorf("lock server instance: %w", err)
}
+5 -4
View File
@@ -9,17 +9,18 @@ import (
)
func TestServerInstanceLockRejectsSecondProcess(t *testing.T) {
database := filepath.Join(t.TempDir(), "vocat.db")
first, err := lockServerInstance(database)
firstDatabase := filepath.Join(t.TempDir(), "vocat.db")
first, err := lockServerInstance(firstDatabase)
if err != nil {
t.Fatal(err)
}
defer first.Close()
second, err := lockServerInstance(database)
secondDatabase := filepath.Join(t.TempDir(), "other.db")
second, err := lockServerInstance(secondDatabase)
if second != nil {
second.Close()
}
if err == nil || !strings.Contains(err.Error(), "already using database") {
if err == nil || !strings.Contains(err.Error(), "already controls this host") {
t.Fatalf("second lock error = %v", err)
}
}
+61 -5
View File
@@ -622,12 +622,18 @@ func configureVoWiFiRuntime(
}
if deviceConfig.VoWiFiEnabled {
if entry, mapErr := mapper.Get(deviceConfig.ID); mapErr == nil {
flightContext, cancelFlight := context.WithTimeout(ctx, 10*time.Second)
_, flightErr := deviceManager.SetFlight(flightContext, entry.ID, true)
cancelFlight()
flightErr := protectVoWiFiStartupRadio(ctx, deviceManager, entry.ID)
if flightErr != nil {
_ = manager.Close(context.Background())
return nil, fmt.Errorf("protect device %q before VoWiFi startup: %w", deviceConfig.ID, flightErr)
// A modem can be temporarily unavailable while OpenWrt/procd is
// restarting the service (notably after loading XFRM modules). Do
// not take the Web/API service down with it: the orchestrator below
// remains fail-closed and its runtime manager retries until CFUN=4
// can be established.
logger.Warn(
"VoWiFi startup radio protection deferred to automatic retry",
"device_id", deviceConfig.ID,
"error", flightErr,
)
}
}
if _, err := manager.RequestEnabled(deviceConfig.ID, true); err != nil {
@@ -639,6 +645,56 @@ func configureVoWiFiRuntime(
return manager, nil
}
const (
vowifiStartupRadioAttempts = 3
vowifiStartupRadioDelay = time.Second
)
type flightModeSetter interface {
SetFlight(context.Context, string, bool) (device.FlightResult, error)
}
func protectVoWiFiStartupRadio(ctx context.Context, manager flightModeSetter, physicalID string) error {
return protectVoWiFiStartupRadioWithRetry(
ctx,
manager,
physicalID,
vowifiStartupRadioAttempts,
vowifiStartupRadioDelay,
)
}
func protectVoWiFiStartupRadioWithRetry(
ctx context.Context,
manager flightModeSetter,
physicalID string,
attempts int,
delay time.Duration,
) error {
var lastErr error
for attempt := 0; attempt < attempts; attempt++ {
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
_, lastErr = manager.SetFlight(flightContext, physicalID, true)
cancel()
if lastErr == nil {
return nil
}
if attempt+1 == attempts {
break
}
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return errors.Join(lastErr, ctx.Err())
case <-timer.C:
}
}
return lastErr
}
type vowifiDeviceAdapter interface {
vowifi.SIMIdentityReader
vowifi.AKAProvider
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"context"
"errors"
"testing"
"vocat/internal/device"
)
type startupFlightSetter struct {
errors []error
calls int
id string
}
func (setter *startupFlightSetter) SetFlight(
_ context.Context,
id string,
enabled bool,
) (device.FlightResult, error) {
setter.calls++
setter.id = id
if !enabled {
return device.FlightResult{}, errors.New("expected flight mode to be enabled")
}
if setter.calls <= len(setter.errors) {
return device.FlightResult{}, setter.errors[setter.calls-1]
}
return device.FlightResult{CurrentMode: 4, FlightMode: true, RadioOff: true}, nil
}
func TestProtectVoWiFiStartupRadioRetriesTransientFailure(t *testing.T) {
transient := errors.New("modem is reopening")
setter := &startupFlightSetter{errors: []error{transient, transient}}
if err := protectVoWiFiStartupRadioWithRetry(
context.Background(), setter, "quectel-1", 3, 0,
); err != nil {
t.Fatalf("protect startup radio: %v", err)
}
if setter.calls != 3 || setter.id != "quectel-1" {
t.Fatalf("SetFlight calls = %d, id = %q", setter.calls, setter.id)
}
}
func TestProtectVoWiFiStartupRadioReturnsLastFailure(t *testing.T) {
first := errors.New("first")
last := errors.New("last")
setter := &startupFlightSetter{errors: []error{first, last}}
err := protectVoWiFiStartupRadioWithRetry(
context.Background(), setter, "quectel-1", 2, 0,
)
if !errors.Is(err, last) || setter.calls != 2 {
t.Fatalf("protect startup radio = %v after %d calls", err, setter.calls)
}
}
+23 -9
View File
@@ -246,6 +246,15 @@ func (manager *Manager) openEuiccAID(ctx context.Context, id, aidHex string) (*e
return channel, nil
}
lastErr = err
if attempt == 0 && errors.Is(err, errNoLogicalChannel) &&
manager.releaseStaleEuiccChannel(ctx, id) {
// EC20 firmware exposes only one MANAGE CHANNEL slot. A canceled or
// interrupted APDU transaction can leave channel 1 allocated, after
// which every eSIM page load returns 6A81 until reboot. Closing the
// orphan while holding the shared UICC transaction lock makes the
// operation self-healing without disturbing an active AKA exchange.
continue
}
if !isTransientEuiccCME(err) {
return nil, err
}
@@ -262,6 +271,11 @@ func (manager *Manager) openEuiccAID(ctx context.Context, id, aidHex string) (*e
return nil, lastErr
}
func (manager *Manager) releaseStaleEuiccChannel(ctx context.Context, id string) bool {
_, sw, err := manager.csim(ctx, id, []byte{0x00, 0x70, 0x80, 0x01, 0x00})
return err == nil && sw == 0x9000
}
func (manager *Manager) openEuiccOnce(ctx context.Context, id string) (*euiccChannel, error) {
return manager.openEuiccOnceAID(ctx, id, isdRAID)
}
@@ -601,8 +615,8 @@ func validProfileICCID(iccid string) bool {
// ESIMListProfiles reads the eUICC profile list via ES10c GetProfilesInfo.
func (manager *Manager) ESIMListProfiles(ctx context.Context, id string) (EsimInfo, error) {
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
manager.lockESIM()
defer manager.unlockESIM()
if manager.esimRecoveryActive(id) {
if cached, ok := manager.cachedESIMInfo(id); ok {
return cached, nil
@@ -642,14 +656,14 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
if err != nil {
return err
}
manager.esimMu.Lock()
manager.lockESIM()
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
manager.esimMu.Unlock()
manager.unlockESIM()
return err
}
channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(aidHex))
if err != nil {
manager.esimMu.Unlock()
manager.unlockESIM()
return err
}
@@ -676,7 +690,7 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
// detached reset is safe in either case and prevents an uncertain switch
// from leaving the modem's SIM cache unusable.
manager.startProfileSwitchRecovery(id)
manager.esimMu.Unlock()
manager.unlockESIM()
return err
}
// A transport SW 9000 only means the APDU reached the eUICC. The real outcome
@@ -685,11 +699,11 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
result, ok := enableProfileResult(payload)
if !ok {
manager.startProfileSwitchRecovery(id)
manager.esimMu.Unlock()
manager.unlockESIM()
return fmt.Errorf("esim: unexpected EnableProfile response %s", strings.ToUpper(hex.EncodeToString(payload)))
}
if err := enableProfileResponseError(byte(result), payload); err != nil {
manager.esimMu.Unlock()
manager.unlockESIM()
return err
}
manager.markCachedProfileEnabled(id, iccid)
@@ -697,7 +711,7 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
// a detached recovery so it survives an HTTP disconnect, but keep this API
// call pending until the live modem ICCID proves that the switch took effect.
manager.startProfileSwitchRecovery(id)
manager.esimMu.Unlock()
manager.unlockESIM()
verifyContext, cancelVerify := context.WithTimeout(context.WithoutCancel(ctx), profileSwitchVerificationTimeout(manager))
defer cancelVerify()
+2 -2
View File
@@ -69,8 +69,8 @@ func (manager *Manager) ESIMDeleteProfile(ctx context.Context, id, iccid, aidHex
if err != nil {
return nil, err
}
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
manager.lockESIM()
defer manager.unlockESIM()
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
return nil, err
}
+2 -2
View File
@@ -63,8 +63,8 @@ func (manager *Manager) ESIMDisableProfile(ctx context.Context, id, iccid, aidHe
if err != nil {
return err
}
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
manager.lockESIM()
defer manager.unlockESIM()
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
return err
}
+6 -6
View File
@@ -48,8 +48,8 @@ func (manager *Manager) ESIMDownloadProfile(ctx context.Context, id string, para
}
}
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
manager.lockESIM()
defer manager.unlockESIM()
report("preflight", "正在检查 eUICC 剩余空间...", 10)
channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(params.AIDHex))
@@ -228,8 +228,8 @@ type EsimChipInfo struct {
// ESIMChipInfo reads the eUICC's EID, EUICCInfo2, and configured addresses for
// the chip header. It takes the eSIM lock like the other card ops.
func (manager *Manager) ESIMChipInfo(ctx context.Context, id string) (*EsimChipInfo, error) {
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
manager.lockESIM()
defer manager.unlockESIM()
var lastErr error
for _, aid := range manager.discoverEuiccAIDs(ctx, id) {
@@ -286,8 +286,8 @@ func readEsimChipInfo(ctx context.Context, channel *euiccChannel, aidHex string)
// the inserted card. It is entirely read-only: only SELECT, GetProfilesInfo,
// GetEuiccData, GetEuiccInfo2 and GetEuiccConfiguredAddresses are issued.
func (manager *Manager) ESIMInventory(ctx context.Context, id string) ([]EsimInventoryEntry, error) {
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
manager.lockESIM()
defer manager.unlockESIM()
if manager.esimRecoveryActive(id) {
return nil, errESIMRecovering
}
+4 -4
View File
@@ -295,8 +295,8 @@ func (channel *euiccChannel) deliverPendingNotifications(ctx context.Context) er
// ESIMNotifications returns the notifications retained across every eUICC
// storage exposed by the physical card.
func (manager *Manager) ESIMNotifications(ctx context.Context, id string) ([]EsimNotification, error) {
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
manager.lockESIM()
defer manager.unlockESIM()
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
return nil, err
}
@@ -331,8 +331,8 @@ func (manager *Manager) ESIMNotifications(ctx context.Context, id string) ([]Esi
// ESIMRetryNotification sends one retained notification and removes it from the
// eUICC only after the receiver returns the SGP.22 success acknowledgement.
func (manager *Manager) ESIMRetryNotification(ctx context.Context, id, aidHex string, sequenceNumber uint64) error {
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
manager.lockESIM()
defer manager.unlockESIM()
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
return err
}
+2 -2
View File
@@ -49,8 +49,8 @@ func (manager *Manager) ESIMRenameProfile(ctx context.Context, id, iccid, nickna
if err != nil {
return err
}
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
manager.lockESIM()
defer manager.unlockESIM()
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
return err
}
+40
View File
@@ -294,6 +294,46 @@ func TestEUICCChannelStuckWrapsTransientCME(t *testing.T) {
}
}
func TestOpenEuiccRecoversOrphanedSingleLogicalChannel(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{
command: `AT+CSIM=10,"0070000001"`,
response: okResponse(`+CSIM: 6,"006A81"`),
},
{
command: `AT+CSIM=10,"0070800100"`,
response: okResponse(`+CSIM: 4,"9000"`),
},
{
command: `AT+CSIM=10,"0070000001"`,
response: okResponse(`+CSIM: 6,"019000"`),
},
{
command: fmt.Sprintf(
`AT+CSIM=42,"01A4040010%s"`,
isdRAID,
),
response: okResponse(`+CSIM: 4,"9000"`),
},
{
command: `AT+CSIM=10,"0070800100"`,
response: okResponse(`+CSIM: 4,"9000"`),
},
}}
manager, id := newStartedTestManager(t, client)
manager.lockESIM()
channel, err := manager.openEuiccAID(context.Background(), id, isdRAID)
if err == nil {
channel.close(context.Background())
}
manager.unlockESIM()
if err != nil {
t.Fatalf("open eUICC after orphaned channel: %v", err)
}
client.assertDone(t)
}
func TestWaitForESIMRecovery(t *testing.T) {
done := make(chan struct{})
manager := &Manager{esimRecoveries: map[string]chan struct{}{"dev": done}}
+18
View File
@@ -25,6 +25,7 @@ type Options struct {
type Manager struct {
mu sync.RWMutex
uiccMu sync.Mutex // serializes all multi-command UICC/APDU transactions
esimMu sync.Mutex // serializes eSIM card access (list/switch/download)
esimRecoveryMu sync.Mutex
esimRecoveries map[string]chan struct{}
@@ -42,6 +43,23 @@ type Manager struct {
ussdSessions map[string]ussdSession
}
// LockUICC and UnlockUICC allow another in-process UICC client (currently the
// VoWiFi AKA adapter) to share the same transaction boundary as eSIM ES10.
// Individual AT commands are already serialized per modem, but a logical-
// channel transaction spans several commands and must not be interleaved.
func (manager *Manager) LockUICC() { manager.uiccMu.Lock() }
func (manager *Manager) UnlockUICC() { manager.uiccMu.Unlock() }
func (manager *Manager) lockESIM() {
manager.esimMu.Lock()
manager.uiccMu.Lock()
}
func (manager *Manager) unlockESIM() {
manager.uiccMu.Unlock()
manager.esimMu.Unlock()
}
// ussdSession tracks an open USSD dialog on a device so a follow-up Continue or
// Cancel can be routed back to the right modem. The modem owns the actual
// network session; this map only records which device a session id belongs to.
+13
View File
@@ -47,6 +47,11 @@ type EC20SensitiveATExecutor interface {
ExecuteSensitiveAT(context.Context, string, string) (modem.Response, error)
}
type EC20UICCLocker interface {
LockUICC()
UnlockUICC()
}
type EC20AdapterOptions struct {
// PureAirplanePolicy reports the independent user policy. The adapter only
// changes the transactional CFUN projection used by VoWiFi and never
@@ -339,6 +344,10 @@ func (adapter *EC20Adapter) CheckReady(
// cannot insert an APDU between a 61xx response and GET RESPONSE.
adapter.apduMu.Lock()
defer adapter.apduMu.Unlock()
if locker, ok := adapter.executor.(EC20UICCLocker); ok {
locker.LockUICC()
defer locker.UnlockUICC()
}
aid, application, err := adapter.discoverAKAApplication(ctx, binding.deviceID)
if err != nil {
@@ -402,6 +411,10 @@ func (adapter *EC20Adapter) Authenticate(
adapter.apduMu.Lock()
defer adapter.apduMu.Unlock()
if locker, ok := adapter.executor.(EC20UICCLocker); ok {
locker.LockUICC()
defer locker.UnlockUICC()
}
apdu := buildUSIMAuthenticateAPDU(challenge)
var raw []byte
+17
View File
@@ -24,6 +24,23 @@ type ATMapper struct {
Devices ATDeviceController
}
type uiccLocker interface {
LockUICC()
UnlockUICC()
}
func (mapper ATMapper) LockUICC() {
if locker, ok := mapper.Devices.(uiccLocker); ok {
locker.LockUICC()
}
}
func (mapper ATMapper) UnlockUICC() {
if locker, ok := mapper.Devices.(uiccLocker); ok {
locker.UnlockUICC()
}
}
func (mapper ATMapper) Get(configuredID string) (device.Device, error) {
physicalID, err := mapper.resolve(context.Background(), configuredID)
if err != nil {
+32 -5
View File
@@ -337,6 +337,8 @@ TimeoutStartSec=30s
# HTTP, VoWiFi, and modem cleanup have bounded shutdown contexts totalling up
# to 30 seconds. Leave a small margin before systemd resorts to SIGKILL.
TimeoutStopSec=40s
RuntimeDirectory=vocat
RuntimeDirectoryMode=0755
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW
@@ -408,12 +410,37 @@ write_service() {
enable_and_start() {
if [ -x "$OPENWRT_INIT_PATH" ] && { [ -x /sbin/procd ] || [ -x /sbin/ubusd ]; }; then
"$OPENWRT_INIT_PATH" enable
# Stop explicitly before restart. Some procd/rc.common variants return
# from restart while the previous process is still inside its bounded
# VoWiFi cleanup, so the replacement can race the host-wide instance
# lock and enter a respawn cycle.
"$OPENWRT_INIT_PATH" stop || true
local stop_attempt
for stop_attempt in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40; do
if ! "$OPENWRT_INIT_PATH" running; then
break
fi
sleep 1
done
if "$OPENWRT_INIT_PATH" restart; then
sleep 2
if "$OPENWRT_INIT_PATH" running; then
rm -f "${BINARY_PATH}.bak"
return
fi
# Modems may need several seconds to release and reopen their AT
# port after procd stops the previous process. Require consecutive
# healthy observations so a short-lived respawn is not mistaken for
# a successful upgrade.
local attempt stable
stable=0
for attempt in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30; do
sleep 1
if "$OPENWRT_INIT_PATH" running; then
stable=$((stable + 1))
if [ "$stable" -ge 3 ]; then
rm -f "${BINARY_PATH}.bak"
return
fi
else
stable=0
fi
done
fi
if [ -e "${BINARY_PATH}.bak" ]; then
cp -a "${BINARY_PATH}.bak" "$BINARY_PATH"