From f17d925c4cfaba0d6ad1cdaa85ce8bd0183b8f0b Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:58:02 +0800 Subject: [PATCH] Fix static ARM builds and OpenWrt VoWiFi setup --- .github/workflows/release.yml | 8 + README.md | 20 ++ cmd/vocat/instance_lock_linux.go | 32 ++ cmd/vocat/instance_lock_linux_test.go | 25 ++ cmd/vocat/instance_lock_other.go | 12 + cmd/vocat/main.go | 5 + docs/README.zh-CN.md | 16 + go.mod | 2 - go.sum | 4 - internal/pcsc/backend_linux.go | 188 +++++------- internal/pcsc/pcscd_protocol.go | 334 +++++++++++++++++++++ internal/pcsc/pcscd_protocol_test.go | 127 ++++++++ internal/update/update.go | 40 ++- internal/update/update_validation_test.go | 44 +++ internal/vowifi/ims/security_linux.go | 8 + internal/vowifi/ims/security_linux_test.go | 13 + scripts/install.sh | 208 ++++++++++++- scripts/vocat.openwrt.init | 30 ++ 18 files changed, 980 insertions(+), 136 deletions(-) create mode 100644 cmd/vocat/instance_lock_linux.go create mode 100644 cmd/vocat/instance_lock_linux_test.go create mode 100644 cmd/vocat/instance_lock_other.go create mode 100644 internal/pcsc/pcscd_protocol.go create mode 100644 internal/pcsc/pcscd_protocol_test.go create mode 100644 internal/update/update_validation_test.go create mode 100644 scripts/vocat.openwrt.init diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19130db..1a8149e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -110,6 +110,14 @@ jobs: -o "$OUTPUT" \ ./cmd/vocat chmod 0755 "$OUTPUT" + if readelf -l "$OUTPUT" | grep -q 'Requesting program interpreter'; then + echo "ERROR: $OUTPUT unexpectedly requires a dynamic loader" >&2 + readelf -l "$OUTPUT" >&2 + exit 1 + fi + if [ "${{ matrix.goarch }}" = "amd64" ]; then + "$OUTPUT" version + fi - name: Upload ${{ matrix.target }} uses: actions/upload-artifact@v4 with: diff --git a/README.md b/README.md index bb68b71..ddb520b 100644 --- a/README.md +++ b/README.md @@ -65,10 +65,24 @@ Available features depend on the module firmware, USB composition, SIM/eSIM capa ### One-click Linux installation +As root (including OpenWrt/Kwrt, where `sudo` is normally absent): + +```bash +curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash +``` + +From a normal user on a distribution with sudo: + ```bash curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash ``` +Check the host's VoWiFi/XFRM prerequisites without installing VoCat: + +```bash +curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env +``` + Install a specific version: ```bash @@ -76,6 +90,12 @@ curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/i sudo bash install.sh 0.0.2 ``` +VoWiFi IMS requires Linux XFRM/IPsec. On OpenWrt/Kwrt the installer attempts +to install matching `ip-full`, `kmod-ipsec`, `kmod-ipsec4/6`, +`kmod-crypto-authenc`, AES-CBC and SHA1 packages from the firmware's own feed. +If matching kernel modules are unavailable, use a firmware that includes them; +never force-install kmods built for a different kernel. + The installer: - detects `amd64`, `386`, `arm64`, `aarch64`, or `armv7`; diff --git a/cmd/vocat/instance_lock_linux.go b/cmd/vocat/instance_lock_linux.go new file mode 100644 index 0000000..7a6ee43 --- /dev/null +++ b/cmd/vocat/instance_lock_linux.go @@ -0,0 +1,32 @@ +//go:build linux + +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "golang.org/x/sys/unix" +) + +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) + } + path := filepath.Join(directory, ".vocat.lock") + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 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.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, fmt.Errorf("lock server instance: %w", err) + } + return file, nil +} diff --git a/cmd/vocat/instance_lock_linux_test.go b/cmd/vocat/instance_lock_linux_test.go new file mode 100644 index 0000000..ab09079 --- /dev/null +++ b/cmd/vocat/instance_lock_linux_test.go @@ -0,0 +1,25 @@ +//go:build linux + +package main + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestServerInstanceLockRejectsSecondProcess(t *testing.T) { + database := filepath.Join(t.TempDir(), "vocat.db") + first, err := lockServerInstance(database) + if err != nil { + t.Fatal(err) + } + defer first.Close() + second, err := lockServerInstance(database) + if second != nil { + second.Close() + } + if err == nil || !strings.Contains(err.Error(), "already using database") { + t.Fatalf("second lock error = %v", err) + } +} diff --git a/cmd/vocat/instance_lock_other.go b/cmd/vocat/instance_lock_other.go new file mode 100644 index 0000000..d214584 --- /dev/null +++ b/cmd/vocat/instance_lock_other.go @@ -0,0 +1,12 @@ +//go:build !linux + +package main + +import ( + "os" + "path/filepath" +) + +func lockServerInstance(databasePath string) (*os.File, error) { + return os.OpenFile(filepath.Join(filepath.Dir(databasePath), ".vocat.lock"), os.O_CREATE|os.O_RDWR, 0o600) +} diff --git a/cmd/vocat/main.go b/cmd/vocat/main.go index d6958bc..b59569d 100644 --- a/cmd/vocat/main.go +++ b/cmd/vocat/main.go @@ -112,6 +112,11 @@ func run(logger *slog.Logger, logs *loghub.Hub) error { if err != nil { return fmt.Errorf("load configuration: %w", err) } + instanceLock, err := lockServerInstance(cfg.DatabasePath) + if err != nil { + return err + } + defer instanceLock.Close() if cfg.UsesDefaultCredentials() { logger.Warn( "default admin credentials are active; set VOCAT_ADMIN_PASSWORD before exposing the service", diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 3f5279d..4692b08 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -65,10 +65,24 @@ Vocat 面向基于高通芯片、并暴露兼容 AT、QMI、串口与 USB 网络 ### Linux 一键安装 +已是 root(包括默认没有 `sudo` 的 OpenWrt/Kwrt): + +```bash +curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash +``` + +普通 Linux 用户且系统装有 sudo: + ```bash curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash ``` +只检查 VoWiFi/XFRM 环境,不安装 VoCat: + +```bash +curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env +``` + 安装指定版本: ```bash @@ -76,6 +90,8 @@ curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/i sudo bash install.sh 0.0.2 ``` +VoWiFi IMS 必须使用 Linux XFRM/IPsec。OpenWrt/Kwrt 上安装脚本会从当前固件自己的软件源尝试安装严格匹配的 `ip-full`、`kmod-ipsec`、`kmod-ipsec4/6`、`kmod-crypto-authenc`、AES-CBC 和 SHA1 组件。若软件源没有与当前内核匹配的模块,必须更换包含这些组件的固件,禁止强装其他内核版本的 kmod。 + 安装程序会: - 检测 `amd64`、`386`、`arm64` 或 `armv7` 架构; diff --git a/go.mod b/go.mod index 8697dc6..6cadd71 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module vocat go 1.25.0 require ( - github.com/ElMostafaIdrassi/goscard v1.0.0 github.com/coder/websocket v1.8.15 go.bug.st/serial v1.6.4 golang.org/x/crypto v0.52.0 @@ -15,7 +14,6 @@ require ( require ( github.com/creack/goselect v0.1.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/ebitengine/purego v0.8.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect diff --git a/go.sum b/go.sum index 5b68020..34c0320 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/ElMostafaIdrassi/goscard v1.0.0 h1:RDG5QrqrQBUoi5MkzM4zILdYf8qDn62daYZszqvdgx0= -github.com/ElMostafaIdrassi/goscard v1.0.0/go.mod h1:uGOakQe2fFlW2cVlr9cv6x07uelrf0j0aKPbR7jGgfg= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= @@ -8,8 +6,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= -github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/internal/pcsc/backend_linux.go b/internal/pcsc/backend_linux.go index 97c955d..b970ee6 100644 --- a/internal/pcsc/backend_linux.go +++ b/internal/pcsc/backend_linux.go @@ -5,89 +5,87 @@ package pcsc import ( "context" "encoding/binary" + "encoding/hex" "errors" "fmt" + "net" "os" "path/filepath" "strconv" "strings" - "sync" - - "github.com/ElMostafaIdrassi/goscard" + "time" ) -type nativeBackend struct { - initializeOnce sync.Once - initializeErr error -} +type nativeBackend struct{} func newNativeBackend() Backend { return &nativeBackend{} } -func (backend *nativeBackend) initialize() error { - backend.initializeOnce.Do(func() { - if err := goscard.Initialize(goscard.NewDefaultLogger(goscard.LogLevelNone)); err != nil { - backend.initializeErr = fmt.Errorf("%w: pcsc-lite client library could not be loaded", ErrUnavailable) +func (backend *nativeBackend) dial(ctx context.Context) (*pcscdClient, error) { + paths := []string{strings.TrimSpace(os.Getenv("PCSCLITE_CSOCK_NAME")), "/run/pcscd/pcscd.comm", "/var/run/pcscd/pcscd.comm"} + var failures []error + seen := make(map[string]bool) + for _, path := range paths { + if path == "" || seen[path] { + continue } - }) - return backend.initializeErr + seen[path] = true + conn, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, "unix", path) + if err != nil { + failures = append(failures, err) + continue + } + client, err := establishPCSCD(ctx, conn) + if err == nil { + return client, nil + } + _ = conn.Close() + failures = append(failures, err) + } + return nil, fmt.Errorf("%w: pcscd socket is not reachable: %w", ErrUnavailable, errors.Join(failures...)) } func (backend *nativeBackend) Readers(ctx context.Context) ([]Reader, error) { - if err := ctx.Err(); err != nil { + client, err := backend.dial(ctx) + if err != nil { return nil, err } - if err := backend.initialize(); err != nil { + defer client.closeContext(context.Background()) + states, err := client.readers(ctx) + if err != nil { return nil, err } - cardContext, _, err := goscard.NewContext(goscard.SCardScopeSystem, nil, nil) - if err != nil { - return nil, fmt.Errorf("%w: pcscd is not reachable", ErrUnavailable) - } - defer cardContext.Release() - names, _, err := cardContext.ListReaders(nil) - if err != nil { - if strings.Contains(strings.ToLower(err.Error()), "no readers") { - return []Reader{}, nil + readers := make([]Reader, 0, len(states)) + for _, state := range states { + reader := Reader{ + Name: state.name, + CardPresent: state.state&pcscCardPresent != 0, + ATR: strings.ToUpper(hex.EncodeToString(state.atr)), } - return nil, fmt.Errorf("pcsc: list readers: %w", err) - } - presentNames, atrs, _, _ := cardContext.ListReadersWithCardPresent(nil) - present := make(map[string]string, len(presentNames)) - for index, name := range presentNames { - atr := "" - if index < len(atrs) { - atr = atrs[index] - } - present[name] = atr - } - readers := make([]Reader, 0, len(names)) - for _, name := range names { - reader := Reader{Name: name} - reader.ATR, reader.CardPresent = present[name] - if path, ok := backend.readerUSBPath(cardContext, name); ok { + if path, ok := backend.readerUSBPath(ctx, client, state.name); ok { reader.USBPath = path reader.VendorID = readSysfsText(path, "idVendor") reader.ProductID = readSysfsText(path, "idProduct") reader.Manufacturer = readSysfsText(path, "manufacturer") reader.Product = readSysfsText(path, "product") } else { - reader.USBPath = "pcsc:" + name + reader.USBPath = "pcsc:" + state.name } if reader.Product == "" { - reader.Product = strings.TrimSpace(strings.TrimSuffix(name, " 00 00")) + reader.Product = strings.TrimSpace(strings.TrimSuffix(state.name, " 00 00")) } readers = append(readers, reader) } return readers, nil } -func (backend *nativeBackend) readerUSBPath(cardContext goscard.Context, name string) (string, bool) { - card, _, err := cardContext.Connect(name, goscard.SCardShareDirect, goscard.SCardProtocolT0|goscard.SCardProtocolT1) +func (backend *nativeBackend) readerUSBPath(ctx context.Context, client *pcscdClient, name string) (string, bool) { + card, _, err := client.connect(ctx, name, pcscShareDirect, 0) if err != nil { return "", false } - defer card.Disconnect(goscard.SCardLeaveCard) - attribute, _, err := card.GetAttrib(goscard.SCardAttrChannelID) + disposition := uint32(pcscLeaveCard) + defer client.simpleCardCommand(context.Background(), pcscCmdDisconnect, card, &disposition) + attribute, err := client.getAttrib(ctx, card, pcscAttrChannelID) if err != nil || len(attribute) < 4 { return "", false } @@ -95,7 +93,7 @@ func (backend *nativeBackend) readerUSBPath(cardContext goscard.Context, name st if channel>>16 != 0x0020 { return "", false } - bus, device := int((channel>>8)&0xFF), int(channel&0xFF) + bus, device := int((channel>>8)&0xff), int(channel&0xff) entries, err := os.ReadDir("/sys/bus/usb/devices") if err != nil { return "", false @@ -115,9 +113,6 @@ func (backend *nativeBackend) readerUSBPath(cardContext goscard.Context, name st } func (backend *nativeBackend) Open(ctx context.Context, selector Selector) (Card, error) { - if err := ctx.Err(); err != nil { - return nil, err - } readers, err := backend.Readers(ctx) if err != nil { return nil, err @@ -129,63 +124,49 @@ func (backend *nativeBackend) Open(ctx context.Context, selector Selector) (Card if !reader.CardPresent { return nil, ErrNoCard } - cardContext, _, err := goscard.NewContext(goscard.SCardScopeSystem, nil, nil) + client, err := backend.dial(ctx) if err != nil { - return nil, fmt.Errorf("%w: create context", ErrUnavailable) + return nil, err } - card, _, err := cardContext.Connect(reader.Name, goscard.SCardShareShared, goscard.SCardProtocolT0|goscard.SCardProtocolT1) + handle, protocol, err := client.connect(ctx, reader.Name, pcscShareShared, pcscProtocolAny) if err != nil { - cardContext.Release() - return nil, fmt.Errorf("pcsc: connect reader: %w", err) + _ = client.closeContext(context.Background()) + return nil, err } - if _, err := card.BeginTransaction(); err != nil { - card.Disconnect(goscard.SCardLeaveCard) - cardContext.Release() + if err := client.simpleCardCommand(ctx, pcscCmdBeginTransaction, handle, nil); err != nil { + disposition := uint32(pcscLeaveCard) + _ = client.simpleCardCommand(context.Background(), pcscCmdDisconnect, handle, &disposition) + _ = client.closeContext(context.Background()) return nil, fmt.Errorf("pcsc: begin card transaction: %w", err) } - return &nativeCard{context: &cardContext, card: &card}, nil + return &nativeCard{client: client, handle: handle, protocol: protocol}, nil } type nativeCard struct { - context *goscard.Context - card *goscard.Card - closed bool + client *pcscdClient + handle int32 + protocol uint32 + closed bool } func (card *nativeCard) Transmit(ctx context.Context, command []byte) ([]byte, uint16, error) { - if card == nil || card.card == nil || card.closed { + if card == nil || card.client == nil || card.closed { return nil, 0, errors.New("pcsc: card session is closed") } - if err := ctx.Err(); err != nil { - return nil, 0, err - } return card.transmit(ctx, append([]byte(nil), command...), 0) } -// TransmitRaw performs exactly one APDU exchange. Stateful eUICC callers need -// to observe 61xx themselves because GET RESPONSE must target their logical -// channel rather than the basic channel. func (card *nativeCard) TransmitRaw(ctx context.Context, command []byte) ([]byte, uint16, error) { - if card == nil || card.card == nil || card.closed { + if card == nil || card.client == nil || card.closed { return nil, 0, errors.New("pcsc: card session is closed") } - if err := ctx.Err(); err != nil { - return nil, 0, err - } - pci := goscard.SCardIoRequestT0 - if card.card.ActiveProtocol() == goscard.SCardProtocolT1 { - pci = goscard.SCardIoRequestT1 - } - response, _, err := card.card.Transmit(&pci, append([]byte(nil), command...), nil) + response, err := card.client.transmit(ctx, card.handle, card.protocol, append([]byte(nil), command...)) if err != nil { return nil, 0, err } if len(response) < 2 { return nil, 0, errors.New("pcsc: APDU response omitted its status word") } - if err := ctx.Err(); err != nil { - return nil, 0, err - } last := len(response) - 2 return append([]byte(nil), response[:last]...), uint16(response[last])<<8 | uint16(response[last+1]), nil } @@ -194,61 +175,46 @@ func (card *nativeCard) transmit(ctx context.Context, command []byte, depth int) if depth > 8 { return nil, 0, errors.New("pcsc: too many APDU continuations") } - pci := goscard.SCardIoRequestT0 - if card.card.ActiveProtocol() == goscard.SCardProtocolT1 { - pci = goscard.SCardIoRequestT1 - } - response, _, err := card.card.Transmit(&pci, command, nil) + data, status, err := card.TransmitRaw(ctx, command) if err != nil { return nil, 0, err } - if len(response) < 2 { - return nil, 0, errors.New("pcsc: APDU response omitted its status word") - } - data := append([]byte(nil), response[:len(response)-2]...) - sw1, sw2 := response[len(response)-2], response[len(response)-1] - if sw1 == 0x6C && len(command) >= 5 { + sw1, sw2 := byte(status>>8), byte(status) + if sw1 == 0x6c && len(command) >= 5 { retry := append([]byte(nil), command...) retry[len(retry)-1] = sw2 return card.transmit(ctx, retry, depth+1) } - if sw1 == 0x61 || sw1 == 0x9F { - more, sw, err := card.transmit(ctx, []byte{0x00, 0xC0, 0x00, 0x00, sw2}, depth+1) + if sw1 == 0x61 || sw1 == 0x9f { + more, sw, err := card.transmit(ctx, []byte{0x00, 0xc0, 0x00, 0x00, sw2}, depth+1) if err != nil { return nil, 0, err } return append(data, more...), sw, nil } - if err := ctx.Err(); err != nil { - return nil, 0, err - } - return data, uint16(sw1)<<8 | uint16(sw2), nil + return data, status, ctx.Err() } -func (card *nativeCard) Close() error { - return card.close(goscard.SCardLeaveCard) -} +func (card *nativeCard) Close() error { return card.close(pcscLeaveCard) } -func (card *nativeCard) CloseWithReset() error { - return card.close(goscard.SCardResetCard) -} +func (card *nativeCard) CloseWithReset() error { return card.close(pcscResetCard) } -func (card *nativeCard) close(disposition goscard.SCardDisposition) error { +func (card *nativeCard) close(disposition uint32) error { if card == nil || card.closed { return nil } card.closed = true + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() var result []error - if card.card != nil { - if _, err := card.card.EndTransaction(disposition); err != nil { + if card.client != nil { + if err := card.client.simpleCardCommand(ctx, pcscCmdEndTransaction, card.handle, &disposition); err != nil { result = append(result, err) } - if _, err := card.card.Disconnect(disposition); err != nil { + if err := card.client.simpleCardCommand(ctx, pcscCmdDisconnect, card.handle, &disposition); err != nil { result = append(result, err) } - } - if card.context != nil { - if _, err := card.context.Release(); err != nil { + if err := card.client.closeContext(ctx); err != nil { result = append(result, err) } } diff --git a/internal/pcsc/pcscd_protocol.go b/internal/pcsc/pcscd_protocol.go new file mode 100644 index 0000000..a5f811f --- /dev/null +++ b/internal/pcsc/pcscd_protocol.go @@ -0,0 +1,334 @@ +package pcsc + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "time" +) + +// pcsc-lite exposes a small, versioned protocol over its local Unix socket. +// Speaking that protocol directly keeps VoCat's Linux binaries fully static; +// loading libpcsclite through dlopen would pull a glibc interpreter into an +// otherwise CGO-free build and make it unusable on musl-based routers. +const ( + pcscProtocolMajor = 4 + pcscProtocolCurrentMinor = 6 + pcscProtocolOldestMinor = 4 + + pcscCmdEstablishContext = 0x01 + pcscCmdReleaseContext = 0x02 + pcscCmdConnect = 0x04 + pcscCmdDisconnect = 0x06 + pcscCmdBeginTransaction = 0x07 + pcscCmdEndTransaction = 0x08 + pcscCmdTransmit = 0x09 + pcscCmdGetAttrib = 0x0f + pcscCmdVersion = 0x11 + pcscCmdGetReadersState = 0x12 + + pcscScopeSystem = 0x0002 + pcscProtocolT0 = 0x0001 + pcscProtocolT1 = 0x0002 + pcscProtocolAny = pcscProtocolT0 | pcscProtocolT1 + pcscShareShared = 0x0002 + pcscShareDirect = 0x0003 + pcscLeaveCard = 0x0000 + pcscResetCard = 0x0001 + pcscCardPresent = 0x0004 + pcscAttrChannelID = 0x00020110 + pcscMaxReaderName = 128 + pcscMaxATR = 33 + pcscMaxReaders = 16 + pcscReaderStateSize = 184 + pcscGetSetBodySize = 280 + pcscMaxAttribute = 264 + pcscMaxAPDUResponse = 65548 + pcscDefaultIOTimeout = 30 * time.Second + pcscSuccess = uint32(0) + pcscNoSmartcard = uint32(0x8010000c) + pcscNoService = uint32(0x8010001d) + pcscServiceStopped = uint32(0x8010001e) + pcscNoReaders = uint32(0x8010002e) +) + +type pcscdClient struct { + conn net.Conn + contextID uint32 + serverMinor int32 +} + +type pcscdReaderState struct { + name string + state uint32 + atr []byte + protocol uint32 +} + +func establishPCSCD(ctx context.Context, conn net.Conn) (*pcscdClient, error) { + client := &pcscdClient{conn: conn} + version := make([]byte, 12) + binary.LittleEndian.PutUint32(version[0:4], pcscProtocolMajor) + binary.LittleEndian.PutUint32(version[4:8], pcscProtocolCurrentMinor) + for { + if err := client.exchange(ctx, pcscCmdVersion, version); err != nil { + return nil, err + } + major := int32(binary.LittleEndian.Uint32(version[0:4])) + client.serverMinor = int32(binary.LittleEndian.Uint32(version[4:8])) + rv := binary.LittleEndian.Uint32(version[8:12]) + if rv == pcscSuccess { + break + } + if rv != pcscServiceStopped || major != pcscProtocolMajor || client.serverMinor < pcscProtocolOldestMinor || client.serverMinor >= pcscProtocolCurrentMinor { + return nil, pcscError("negotiate protocol", rv) + } + // pcsc-lite answers a newer client's first probe with its own + // compatible minor version. Retry on the same connection with that + // value, matching libpcsclite's official fallback behavior. + binary.LittleEndian.PutUint32(version[0:4], pcscProtocolMajor) + binary.LittleEndian.PutUint32(version[4:8], uint32(client.serverMinor)) + binary.LittleEndian.PutUint32(version[8:12], pcscSuccess) + } + if client.serverMinor < pcscProtocolOldestMinor { + return nil, fmt.Errorf("pcsc: unsupported pcscd protocol %d.%d", pcscProtocolMajor, client.serverMinor) + } + body := make([]byte, 12) + binary.LittleEndian.PutUint32(body[0:4], pcscScopeSystem) + if err := client.exchange(ctx, pcscCmdEstablishContext, body); err != nil { + return nil, err + } + if rv := binary.LittleEndian.Uint32(body[8:12]); rv != pcscSuccess { + return nil, pcscError("establish context", rv) + } + client.contextID = binary.LittleEndian.Uint32(body[4:8]) + return client, nil +} + +func (client *pcscdClient) exchange(ctx context.Context, command uint32, body []byte) error { + if client == nil || client.conn == nil { + return errors.New("pcsc: pcscd connection is closed") + } + if err := client.setDeadline(ctx); err != nil { + return err + } + header := make([]byte, 8) + binary.LittleEndian.PutUint32(header[0:4], uint32(len(body))) + binary.LittleEndian.PutUint32(header[4:8], command) + if err := writeAll(client.conn, header); err != nil { + return fmt.Errorf("pcsc: send command %02x: %w", command, err) + } + if len(body) > 0 { + if err := writeAll(client.conn, body); err != nil { + return fmt.Errorf("pcsc: send command body %02x: %w", command, err) + } + if _, err := io.ReadFull(client.conn, body); err != nil { + return fmt.Errorf("pcsc: receive command %02x: %w", command, err) + } + } + return nil +} + +func (client *pcscdClient) send(ctx context.Context, command uint32, body, extra []byte) error { + if client == nil || client.conn == nil { + return errors.New("pcsc: pcscd connection is closed") + } + if err := client.setDeadline(ctx); err != nil { + return err + } + header := make([]byte, 8) + binary.LittleEndian.PutUint32(header[0:4], uint32(len(body))) + binary.LittleEndian.PutUint32(header[4:8], command) + if err := writeAll(client.conn, header); err != nil { + return err + } + if err := writeAll(client.conn, body); err != nil { + return err + } + return writeAll(client.conn, extra) +} + +func (client *pcscdClient) setDeadline(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + deadline := time.Now().Add(pcscDefaultIOTimeout) + if value, ok := ctx.Deadline(); ok && value.Before(deadline) { + deadline = value + } + return client.conn.SetDeadline(deadline) +} + +func (client *pcscdClient) readers(ctx context.Context) ([]pcscdReaderState, error) { + if err := client.setDeadline(ctx); err != nil { + return nil, err + } + header := make([]byte, 8) + binary.LittleEndian.PutUint32(header[4:8], pcscCmdGetReadersState) + if err := writeAll(client.conn, header); err != nil { + return nil, fmt.Errorf("pcsc: request reader states: %w", err) + } + raw := make([]byte, pcscMaxReaders*pcscReaderStateSize) + if _, err := io.ReadFull(client.conn, raw); err != nil { + return nil, fmt.Errorf("pcsc: read reader states: %w", err) + } + result := make([]pcscdReaderState, 0, pcscMaxReaders) + for offset := 0; offset < len(raw); offset += pcscReaderStateSize { + state := raw[offset : offset+pcscReaderStateSize] + name := cString(state[:pcscMaxReaderName]) + if name == "" { + continue + } + atrLen := int(binary.LittleEndian.Uint32(state[176:180])) + if atrLen < 0 || atrLen > pcscMaxATR { + atrLen = 0 + } + result = append(result, pcscdReaderState{ + name: name, + state: binary.LittleEndian.Uint32(state[132:136]), + atr: append([]byte(nil), state[140:140+atrLen]...), + protocol: binary.LittleEndian.Uint32(state[180:184]), + }) + } + return result, nil +} + +func (client *pcscdClient) connect(ctx context.Context, reader string, share, protocols uint32) (int32, uint32, error) { + if len(reader) >= pcscMaxReaderName { + return 0, 0, errors.New("pcsc: reader name is too long") + } + body := make([]byte, 152) + binary.LittleEndian.PutUint32(body[0:4], client.contextID) + copy(body[4:132], reader) + binary.LittleEndian.PutUint32(body[132:136], share) + binary.LittleEndian.PutUint32(body[136:140], protocols) + if err := client.exchange(ctx, pcscCmdConnect, body); err != nil { + return 0, 0, err + } + if rv := binary.LittleEndian.Uint32(body[148:152]); rv != pcscSuccess { + return 0, 0, pcscError("connect reader", rv) + } + return int32(binary.LittleEndian.Uint32(body[140:144])), binary.LittleEndian.Uint32(body[144:148]), nil +} + +func (client *pcscdClient) simpleCardCommand(ctx context.Context, command uint32, card int32, disposition *uint32) error { + size := 8 + if disposition != nil { + size = 12 + } + body := make([]byte, size) + binary.LittleEndian.PutUint32(body[0:4], uint32(card)) + if disposition != nil { + binary.LittleEndian.PutUint32(body[4:8], *disposition) + } + if err := client.exchange(ctx, command, body); err != nil { + return err + } + if rv := binary.LittleEndian.Uint32(body[size-4:]); rv != pcscSuccess { + return pcscError("card command", rv) + } + return nil +} + +func (client *pcscdClient) transmit(ctx context.Context, card int32, protocol uint32, command []byte) ([]byte, error) { + body := make([]byte, 32) + binary.LittleEndian.PutUint32(body[0:4], uint32(card)) + binary.LittleEndian.PutUint32(body[4:8], protocol) + binary.LittleEndian.PutUint32(body[8:12], 8) + binary.LittleEndian.PutUint32(body[12:16], uint32(len(command))) + binary.LittleEndian.PutUint32(body[16:20], pcscProtocolAny) + binary.LittleEndian.PutUint32(body[20:24], 8) + binary.LittleEndian.PutUint32(body[24:28], pcscMaxAPDUResponse) + if err := client.send(ctx, pcscCmdTransmit, body, command); err != nil { + return nil, fmt.Errorf("pcsc: transmit APDU: %w", err) + } + if _, err := io.ReadFull(client.conn, body); err != nil { + return nil, fmt.Errorf("pcsc: receive APDU result: %w", err) + } + if rv := binary.LittleEndian.Uint32(body[28:32]); rv != pcscSuccess { + return nil, pcscError("transmit APDU", rv) + } + length := binary.LittleEndian.Uint32(body[24:28]) + if length > pcscMaxAPDUResponse { + return nil, errors.New("pcsc: pcscd returned an oversized APDU") + } + response := make([]byte, length) + if _, err := io.ReadFull(client.conn, response); err != nil { + return nil, fmt.Errorf("pcsc: receive APDU: %w", err) + } + return response, nil +} + +func (client *pcscdClient) getAttrib(ctx context.Context, card int32, attribute uint32) ([]byte, error) { + body := make([]byte, pcscGetSetBodySize) + binary.LittleEndian.PutUint32(body[0:4], uint32(card)) + binary.LittleEndian.PutUint32(body[4:8], attribute) + binary.LittleEndian.PutUint32(body[272:276], pcscMaxAttribute) + if err := client.exchange(ctx, pcscCmdGetAttrib, body); err != nil { + return nil, err + } + if rv := binary.LittleEndian.Uint32(body[276:280]); rv != pcscSuccess { + return nil, pcscError("get reader attribute", rv) + } + length := binary.LittleEndian.Uint32(body[272:276]) + if length > pcscMaxAttribute { + return nil, errors.New("pcsc: pcscd returned an oversized attribute") + } + return append([]byte(nil), body[8:8+length]...), nil +} + +func (client *pcscdClient) closeContext(ctx context.Context) error { + if client == nil || client.conn == nil { + return nil + } + body := make([]byte, 8) + binary.LittleEndian.PutUint32(body[0:4], client.contextID) + err := client.exchange(ctx, pcscCmdReleaseContext, body) + if err == nil { + if rv := binary.LittleEndian.Uint32(body[4:8]); rv != pcscSuccess { + err = pcscError("release context", rv) + } + } + closeErr := client.conn.Close() + client.conn = nil + return errors.Join(err, closeErr) +} + +func pcscError(operation string, code uint32) error { + switch code { + case pcscNoSmartcard: + return ErrNoCard + case pcscNoService, pcscServiceStopped: + return fmt.Errorf("%w: %s failed with PC/SC status %08X", ErrUnavailable, operation, code) + case pcscNoReaders: + return ErrReaderNotFound + default: + return fmt.Errorf("pcsc: %s failed with status %08X", operation, code) + } +} + +func cString(value []byte) string { + for index, current := range value { + if current == 0 { + return string(value[:index]) + } + } + return string(value) +} + +func writeAll(writer io.Writer, value []byte) error { + for len(value) > 0 { + written, err := writer.Write(value) + if err != nil { + return err + } + if written == 0 { + return io.ErrUnexpectedEOF + } + value = value[written:] + } + return nil +} diff --git a/internal/pcsc/pcscd_protocol_test.go b/internal/pcsc/pcscd_protocol_test.go new file mode 100644 index 0000000..518d98e --- /dev/null +++ b/internal/pcsc/pcscd_protocol_test.go @@ -0,0 +1,127 @@ +package pcsc + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "io" + "net" + "testing" +) + +func TestPCSCDClientLifecycleAndTransmit(t *testing.T) { + clientConn, serverConn := net.Pipe() + serverDone := make(chan error, 1) + go func() { + defer serverConn.Close() + serverDone <- servePCSCDTestSession(serverConn) + }() + + client, err := establishPCSCD(context.Background(), clientConn) + if err != nil { + t.Fatalf("establishPCSCD: %v", err) + } + states, err := client.readers(context.Background()) + if err != nil { + t.Fatalf("readers: %v", err) + } + if len(states) != 1 || states[0].name != "VoCat Test Reader 00 00" || states[0].state&pcscCardPresent == 0 { + t.Fatalf("states = %#v", states) + } + handle, protocol, err := client.connect(context.Background(), states[0].name, pcscShareShared, pcscProtocolAny) + if err != nil { + t.Fatalf("connect: %v", err) + } + if handle != 42 || protocol != pcscProtocolT1 { + t.Fatalf("handle/protocol = %d/%d", handle, protocol) + } + if err := client.simpleCardCommand(context.Background(), pcscCmdBeginTransaction, handle, nil); err != nil { + t.Fatalf("begin: %v", err) + } + response, err := client.transmit(context.Background(), handle, protocol, []byte{0x00, 0xa4, 0x00, 0x00}) + if err != nil { + t.Fatalf("transmit: %v", err) + } + if !bytes.Equal(response, []byte{0x62, 0x02, 0x90, 0x00}) { + t.Fatalf("response = %x", response) + } + disposition := uint32(pcscLeaveCard) + if err := client.simpleCardCommand(context.Background(), pcscCmdEndTransaction, handle, &disposition); err != nil { + t.Fatalf("end: %v", err) + } + if err := client.simpleCardCommand(context.Background(), pcscCmdDisconnect, handle, &disposition); err != nil { + t.Fatalf("disconnect: %v", err) + } + if err := client.closeContext(context.Background()); err != nil { + t.Fatalf("close context: %v", err) + } + if err := <-serverDone; err != nil { + t.Fatalf("fake pcscd: %v", err) + } +} + +func servePCSCDTestSession(conn net.Conn) error { + for { + header := make([]byte, 8) + if _, err := io.ReadFull(conn, header); err != nil { + return err + } + size := binary.LittleEndian.Uint32(header[0:4]) + command := binary.LittleEndian.Uint32(header[4:8]) + body := make([]byte, size) + if _, err := io.ReadFull(conn, body); err != nil { + return err + } + switch command { + case pcscCmdVersion: + binary.LittleEndian.PutUint32(body[0:4], pcscProtocolMajor) + binary.LittleEndian.PutUint32(body[4:8], pcscProtocolCurrentMinor) + if err := writeAll(conn, body); err != nil { + return err + } + case pcscCmdEstablishContext: + binary.LittleEndian.PutUint32(body[4:8], 7) + if err := writeAll(conn, body); err != nil { + return err + } + case pcscCmdGetReadersState: + states := make([]byte, pcscMaxReaders*pcscReaderStateSize) + copy(states, "VoCat Test Reader 00 00") + binary.LittleEndian.PutUint32(states[132:136], pcscCardPresent) + copy(states[140:143], []byte{0x3b, 0x00, 0x00}) + binary.LittleEndian.PutUint32(states[176:180], 3) + binary.LittleEndian.PutUint32(states[180:184], pcscProtocolT1) + if err := writeAll(conn, states); err != nil { + return err + } + case pcscCmdConnect: + binary.LittleEndian.PutUint32(body[140:144], 42) + binary.LittleEndian.PutUint32(body[144:148], pcscProtocolT1) + if err := writeAll(conn, body); err != nil { + return err + } + case pcscCmdBeginTransaction, pcscCmdEndTransaction, pcscCmdDisconnect: + if err := writeAll(conn, body); err != nil { + return err + } + case pcscCmdTransmit: + commandBody := make([]byte, binary.LittleEndian.Uint32(body[12:16])) + if _, err := io.ReadFull(conn, commandBody); err != nil { + return err + } + response := []byte{0x62, 0x02, 0x90, 0x00} + binary.LittleEndian.PutUint32(body[24:28], uint32(len(response))) + if err := writeAll(conn, body); err != nil { + return err + } + if err := writeAll(conn, response); err != nil { + return err + } + case pcscCmdReleaseContext: + return writeAll(conn, body) + default: + return errors.New("unexpected fake pcscd command") + } + } +} diff --git a/internal/update/update.go b/internal/update/update.go index 3465545..7342299 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -184,6 +184,10 @@ func applyUpdate(ctx context.Context, logger *slog.Logger, opts Options, release cleanup() return fmt.Errorf("update: chmod temp binary: %w", err) } + if err := validateExecutable(ctx, tmpPath); err != nil { + cleanup() + return err + } if err := backupAndReplace(opts.Target, tmpPath); err != nil { cleanup() return err @@ -202,10 +206,25 @@ func applyUpdate(ctx context.Context, logger *slog.Logger, opts Options, release return nil } +// validateExecutable catches incompatible architectures and missing dynamic +// loaders before the working installation is touched. A valid checksum alone +// cannot detect those packaging errors. +func validateExecutable(ctx context.Context, path string) error { + checkCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + output, err := exec.CommandContext(checkCtx, path, "version").CombinedOutput() + if err != nil { + return fmt.Errorf("update: downloaded binary cannot run on this host: %w (%s)", err, strings.TrimSpace(string(output))) + } + if !strings.Contains(strings.ToLower(string(output)), "vocat") { + return fmt.Errorf("update: downloaded binary returned an unexpected version response: %q", strings.TrimSpace(string(output))) + } + return nil +} + // backupAndReplace renames the current binary aside, then moves the verified -// temp file into place. Both renames are atomic on the same filesystem. On -// Linux the kernel holds the running binary's inode, so replacing it mid-flight -// is safe. +// temp file into place. Both renames are atomic on the same filesystem. The +// previous working binary is retained for service-level or manual rollback. func backupAndReplace(target, tmp string) error { backup := target + ".previous" if _, err := os.Stat(target); err == nil { @@ -221,16 +240,21 @@ func backupAndReplace(target, tmp string) error { } return fmt.Errorf("update: move new binary into place: %w", err) } - _ = os.Remove(backup) return nil } -// RestartService restarts the vocat systemd unit. If systemctl is unavailable -// (non-systemd hosts, containers), it returns an error the caller surfaces as -// a non-fatal warning. +// RestartService supports both systemd hosts and OpenWrt/procd routers. func RestartService(logger *slog.Logger) error { + if _, err := os.Stat("/etc/init.d/vocat"); err == nil { + cmd := exec.Command("/etc/init.d/vocat", "restart") + if out, err := cmd.CombinedOutput(); err != nil { + logger.Warn("OpenWrt service restart failed", "error", err, "output", string(out)) + return fmt.Errorf("restart OpenWrt vocat service: %w", err) + } + return nil + } if _, err := exec.LookPath("systemctl"); err != nil { - return fmt.Errorf("systemctl not found in PATH") + return fmt.Errorf("neither /etc/init.d/vocat nor systemctl is available") } // Queue the restart and let systemctl exit before systemd stops this unit. // A blocking restart command becomes part of vocat.service's own cgroup and diff --git a/internal/update/update_validation_test.go b/internal/update/update_validation_test.go new file mode 100644 index 0000000..39e5f7c --- /dev/null +++ b/internal/update/update_validation_test.go @@ -0,0 +1,44 @@ +package update + +import ( + "context" + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestValidateExecutableRejectsNonExecutableFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "not-vocat") + if err := os.WriteFile(path, []byte("not an executable"), 0o755); err != nil { + t.Fatal(err) + } + if err := validateExecutable(context.Background(), path); err == nil { + t.Fatal("validateExecutable accepted invalid file") + } +} + +func TestBackupAndReplaceRetainsPreviousBinary(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Linux replacement behavior") + } + directory := t.TempDir() + target := filepath.Join(directory, "vocat") + replacement := filepath.Join(directory, "replacement") + if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(replacement, []byte("new"), 0o755); err != nil { + t.Fatal(err) + } + if err := backupAndReplace(target, replacement); err != nil { + t.Fatal(err) + } + old, err := os.ReadFile(target + ".previous") + if err != nil { + t.Fatalf("read retained backup: %v", err) + } + if string(old) != "old" { + t.Fatalf("backup = %q", old) + } +} diff --git a/internal/vowifi/ims/security_linux.go b/internal/vowifi/ims/security_linux.go index 7248ec6..dfdec39 100644 --- a/internal/vowifi/ims/security_linux.go +++ b/internal/vowifi/ims/security_linux.go @@ -84,6 +84,14 @@ func runIPCommand(ctx context.Context, command string, operation xfrmOperation) if message == "" { message = err.Error() } + if strings.Contains(strings.ToLower(message), "protocol not supported") || + strings.Contains(strings.ToLower(message), "operation not supported") { + return fmt.Errorf( + "%s: host kernel lacks XFRM/IPsec support; install matching kmod-ipsec and kmod-ipsec4/6 (OpenWrt), or enable CONFIG_XFRM_USER and ESP in the kernel: %s", + operation.description, + message, + ) + } // Operation descriptions contain no SPI keys or subscriber identity. return fmt.Errorf("%s: %s", operation.description, message) } diff --git a/internal/vowifi/ims/security_linux_test.go b/internal/vowifi/ims/security_linux_test.go index da55195..c541a9c 100644 --- a/internal/vowifi/ims/security_linux_test.go +++ b/internal/vowifi/ims/security_linux_test.go @@ -11,6 +11,19 @@ import ( "time" ) +func TestRunIPCommandExplainsMissingKernelXFRM(t *testing.T) { + directory := t.TempDir() + command := directory + "/ip" + script := "#!/bin/sh\necho 'Cannot open netlink socket: Protocol not supported' >&2\nexit 1\n" + if err := os.WriteFile(command, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + err := runIPCommand(context.Background(), command, xfrmOperation{description: "test state"}) + if err == nil || !strings.Contains(err.Error(), "kmod-ipsec") || !strings.Contains(err.Error(), "CONFIG_XFRM_USER") { + t.Fatalf("error = %v", err) + } +} + func TestLinuxIPSecInstallerLifecycle(t *testing.T) { if os.Getenv("VOCAT_NETNS_TEST") != "1" { t.Skip("set VOCAT_NETNS_TEST=1 inside an isolated Linux network namespace") diff --git a/scripts/install.sh b/scripts/install.sh index dc49d26..75bc277 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,12 +1,11 @@ #!/usr/bin/env bash # -# vocat install / update script for binary + systemd deployments. +# vocat install / update script for systemd and OpenWrt/procd deployments. # # Usage: -# sudo bash install.sh [version] # install a specific version -# sudo bash install.sh # install latest release -# sudo bash install.sh --force # reinstall even at the same version -# curl -fsSL | sudo bash # one-liner (latest) +# bash install.sh [version] # run directly when already root +# sudo bash install.sh [version] # run through sudo as a normal user +# bash install.sh --check-env # check VoWiFi host prerequisites # # Behavior: # - Prompts for script language (中文 / English) as soon as it runs. @@ -14,7 +13,9 @@ # - On first install, generates a random 32-char admin password, writes it to # /etc/vocat/env (0600, loaded by the systemd unit), and prints it ONCE. # - On update, preserves the existing env file and credentials. -# - (Re)writes the systemd unit and restarts the service. +# - Verifies Linux XFRM/IPsec support required by IMS; on OpenWrt it tries +# the matching opkg packages first. +# - (Re)writes a systemd or OpenWrt/procd service and restarts it. # # Published script: must contain no secrets, IPs, or passwords. @@ -31,6 +32,7 @@ LINK_PATH="/usr/local/bin/vocat" ENV_DIR="/etc/vocat" ENV_FILE="${ENV_DIR}/env" UNIT_PATH="/etc/systemd/system/vocat.service" +OPENWRT_INIT_PATH="/etc/init.d/vocat" # --- Language ---------------------------------------------------------------- LANG_CHOICE="" @@ -76,14 +78,44 @@ die() { prompt_language +# BusyBox/OpenWrt images often omit coreutils' install(1). Provide the small +# subset used by this script so the same installer works on router firmware. +if ! command -v install >/dev/null 2>&1; then + install() { + if [ "${1:-}" = "-d" ]; then + shift + local mode="0755" + if [ "${1:-}" = "-m" ]; then + mode="$2" + shift 2 + fi + mkdir -p "$@" + chmod "$mode" "$@" + return + fi + local mode="0755" + if [ "${1:-}" = "-m" ]; then + mode="$2" + shift 2 + fi + [ "$#" -eq 2 ] || return 2 + cp "$1" "$2" + chmod "$mode" "$2" + } +fi + # --- Parse args -------------------------------------------------------------- FORCE=0 +CHECK_ENV=0 +SKIP_VOWIFI_CHECK="${VOCAT_SKIP_VOWIFI_CHECK:-0}" TARGET_VERSION="" for arg in "$@"; do case "$arg" in --force) FORCE=1 ;; + --check-env) CHECK_ENV=1 ;; + --skip-vowifi-check) SKIP_VOWIFI_CHECK=1 ;; -h|--help) - msg "用法: sudo bash install.sh [--force] [版本]" "Usage: sudo bash install.sh [--force] [version]" + msg "用法: bash install.sh [--force] [--check-env] [--skip-vowifi-check] [版本]" "Usage: bash install.sh [--force] [--check-env] [--skip-vowifi-check] [version]" exit 0 ;; *) TARGET_VERSION="${arg#v}" ;; @@ -110,6 +142,89 @@ resolve_target_version() { TARGET_VERSION="${tag#v}" } +# --- Host prerequisites ------------------------------------------------------ +is_openwrt() { + [ -f /etc/openwrt_release ] || [ -x /sbin/procd ] +} + +xfrm_works() { + command -v ip >/dev/null 2>&1 && ip xfrm state list >/dev/null 2>&1 +} + +opkg_has_package() { + opkg list "$1" 2>/dev/null | grep -q "^$1 -" +} + +install_openwrt_vowifi_packages() { + msg "正在检查 OpenWrt/Kwrt 的 VoWiFi 内核组件..." "Checking OpenWrt/Kwrt VoWiFi kernel components..." + opkg update >/dev/null 2>&1 || msg \ + "警告:opkg 软件源更新失败,将使用现有索引继续检查。" \ + "Warning: opkg feed update failed; checking the existing index." + + local packages="" + local package + for package in \ + ip-full \ + kmod-ipsec kmod-ipsec4 kmod-ipsec6 \ + kmod-crypto-authenc kmod-crypto-cbc kmod-crypto-aes \ + kmod-crypto-hmac kmod-crypto-sha1; do + if opkg_has_package "$package"; then + packages="$packages $package" + fi + done + if [ -n "$packages" ]; then + # Kernel packages must come from this firmware's own feed. opkg checks + # the kernel ABI and refuses mismatched modules; never bypass that check. + # shellcheck disable=SC2086 + opkg install $packages >/dev/null 2>&1 || true + fi +} + +install_linux_ip_tool() { + command -v ip >/dev/null 2>&1 && return 0 + if command -v apt-get >/dev/null 2>&1; then + apt-get update -qq && apt-get install -y iproute2 + elif command -v dnf >/dev/null 2>&1; then + dnf install -y iproute + elif command -v yum >/dev/null 2>&1; then + yum install -y iproute + elif command -v pacman >/dev/null 2>&1; then + pacman -Sy --noconfirm iproute2 + elif command -v apk >/dev/null 2>&1; then + apk add --no-cache iproute2 + fi +} + +check_vowifi_environment() { + if [ "$SKIP_VOWIFI_CHECK" = "1" ]; then + msg \ + "已跳过 VoWiFi 内核环境检查;IMS 通话和短信可能不可用。" \ + "Skipped the VoWiFi kernel check; IMS calls and SMS may not work." + return + fi + + if is_openwrt && command -v opkg >/dev/null 2>&1; then + # Install the crypto algorithms even when NETLINK_XFRM already works; + # some minimal images provide xfrm_user but omit AES-CBC/authenc. + install_openwrt_vowifi_packages + elif ! xfrm_works; then + install_linux_ip_tool + fi + if xfrm_works; then + msg "VoWiFi XFRM/IPsec 环境安装并验证成功。" "VoWiFi XFRM/IPsec environment installed and verified." + return + fi + + if is_openwrt; then + die \ + "当前 OpenWrt/Kwrt 内核 $(uname -r) 不支持 NETLINK_XFRM,且软件源没有匹配的 kmod-ipsec。请使用包含 kmod-ipsec、kmod-ipsec4、kmod-ipsec6、kmod-crypto-authenc、kmod-crypto-cbc、kmod-crypto-aes 和 kmod-crypto-sha1 的同版本固件;严禁安装其他内核版本的 kmod。仅使用非 VoWiFi 功能时可加 --skip-vowifi-check。" \ + "The OpenWrt/Kwrt kernel $(uname -r) lacks NETLINK_XFRM and its feed has no matching kmod-ipsec. Use a firmware built with matching kmod-ipsec, kmod-ipsec4/6, crypto-authenc, CBC, AES and SHA1 modules. Never force kmods from another kernel. Use --skip-vowifi-check only for non-VoWiFi operation." + fi + die \ + "当前 Linux 内核不支持 XFRM/IPsec,VoWiFi IMS 无法工作。请启用 CONFIG_XFRM、CONFIG_XFRM_USER、CONFIG_INET_ESP、CONFIG_INET6_ESP、AES-CBC 和 HMAC-SHA1。" \ + "This Linux kernel lacks XFRM/IPsec required by VoWiFi IMS. Enable CONFIG_XFRM, CONFIG_XFRM_USER, CONFIG_INET_ESP, CONFIG_INET6_ESP, AES-CBC and HMAC-SHA1." +} + # --- Skip if already installed at the same version --------------------------- skip_if_equal() { [ -x "$BINARY_PATH" ] || return 0 @@ -159,6 +274,10 @@ download_and_verify() { [ -n "$expected" ] || die "SHA256SUMS 中找不到 $asset 的校验行。" "$asset not found in SHA256SUMS." actual=$(sha256sum "${VOCAT_TMP}/vocat" | awk '{print $1}') [ "$actual" = "$expected" ] || die "SHA-256 校验失败。" "SHA-256 verification failed." + chmod 0755 "${VOCAT_TMP}/vocat" + "${VOCAT_TMP}/vocat" version >/dev/null 2>&1 || die \ + "Downloaded binary cannot run on this system; keeping the installed version." \ + "The downloaded binary cannot run on this host; the installed version was not changed." } # --- Install binary ---------------------------------------------------------- @@ -246,11 +365,73 @@ EOF chmod 0644 "$UNIT_PATH" } +write_openwrt_init() { + cat > "$OPENWRT_INIT_PATH" <<'EOF' +#!/bin/sh /etc/rc.common +START=95 +STOP=10 +USE_PROCD=1 +PROCD_TERM_TIMEOUT=40 +PROGRAM=/opt/vocat/bin/vocat +ENV_FILE=/etc/vocat/env +start_service() { + procd_open_instance + procd_set_param command "$PROGRAM" serve + procd_set_param env VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db + if [ -r "$ENV_FILE" ]; then + while IFS='=' read -r name value; do + case "$name" in VOCAT_*) procd_append_param env "$name=$value" ;; esac + done < "$ENV_FILE" + fi + procd_set_param respawn 3600 5 5 + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_close_instance +} +service_triggers() { procd_add_reload_trigger vocat; } +EOF + chmod 0755 "$OPENWRT_INIT_PATH" +} + +write_service() { + if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then + write_unit + return + fi + if [ -x /sbin/procd ] || [ -x /sbin/ubusd ]; then + write_openwrt_init + return + fi + die "Unsupported service manager." "Neither systemd nor OpenWrt procd was detected." +} + enable_and_start() { + if [ -x "$OPENWRT_INIT_PATH" ] && { [ -x /sbin/procd ] || [ -x /sbin/ubusd ]; }; then + "$OPENWRT_INIT_PATH" enable + if "$OPENWRT_INIT_PATH" restart; then + sleep 2 + if "$OPENWRT_INIT_PATH" running; then + rm -f "${BINARY_PATH}.bak" + return + fi + fi + if [ -e "${BINARY_PATH}.bak" ]; then + cp -a "${BINARY_PATH}.bak" "$BINARY_PATH" + "$OPENWRT_INIT_PATH" restart || true + fi + die "OpenWrt vocat service failed to start." "The OpenWrt vocat service failed to start." + fi systemctl daemon-reload systemctl enable vocat if systemctl restart vocat; then - return + local attempt + for attempt in 1 2 3 4 5; do + if systemctl is-active --quiet vocat; then + rm -f "${BINARY_PATH}.bak" + return + fi + sleep 1 + done fi if [ -e "${BINARY_PATH}.bak" ]; then msg "新版本启动失败,正在恢复旧二进制。" "The new version failed to start; restoring the previous binary." @@ -261,14 +442,19 @@ enable_and_start() { } # --- Main -------------------------------------------------------------------- -resolve_target_version detect_arch +check_vowifi_environment +if [ "$CHECK_ENV" -eq 1 ]; then + msg "VoCat 运行环境检查完成。" "VoCat host environment check completed." + exit 0 +fi +resolve_target_version skip_if_equal download_and_verify install_binary ensure_data_dir setup_env -write_unit +write_service enable_and_start if [ "$FIRST_INSTALL" -eq 1 ]; then @@ -281,7 +467,7 @@ if [ "$FIRST_INSTALL" -eq 1 ]; then echo msg "用户名为 admin。请立即记录此密码。" "Username is admin. Record this password now." msg "登录后或运行以下命令修改密码:" "Change it via the web UI or run:" - echo " sudo vocat menu" + echo " vocat menu" msg "==========================================" "==============================================" else echo diff --git a/scripts/vocat.openwrt.init b/scripts/vocat.openwrt.init new file mode 100644 index 0000000..2b709af --- /dev/null +++ b/scripts/vocat.openwrt.init @@ -0,0 +1,30 @@ +#!/bin/sh /etc/rc.common + +START=95 +STOP=10 +USE_PROCD=1 +PROCD_TERM_TIMEOUT=40 + +PROGRAM=/opt/vocat/bin/vocat +ENV_FILE=/etc/vocat/env + +start_service() { + procd_open_instance + procd_set_param command "$PROGRAM" serve + procd_set_param env VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db + if [ -r "$ENV_FILE" ]; then + while IFS='=' read -r name value; do + case "$name" in + VOCAT_*) procd_append_param env "$name=$value" ;; + esac + done < "$ENV_FILE" + fi + procd_set_param respawn 3600 5 5 + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_close_instance +} + +service_triggers() { + procd_add_reload_trigger vocat +}