3 Commits
Author SHA1 Message Date
MengMengCode f17d925c4c Fix static ARM builds and OpenWrt VoWiFi setup 2026-08-12 15:58:02 +08:00
Meng MengandGitHub 296f963885 Merge pull request #3 from MengMengCode/dependabot/go_modules/go_modules-a3c8a40308
build(deps): bump golang.org/x/crypto from 0.41.0 to 0.52.0 in the go_modules group across 1 directory
2026-08-12 02:38:34 +08:00
dependabot[bot]andGitHub 1b9546a73d build(deps): bump golang.org/x/crypto
Bumps the go_modules group with 1 update in the / directory: [golang.org/x/crypto](https://github.com/golang/crypto).


Updates `golang.org/x/crypto` from 0.41.0 to 0.52.0
- [Commits](https://github.com/golang/crypto/compare/v0.41.0...v0.52.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.52.0
  dependency-type: direct:production
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-08-11 18:35:30 +00:00
18 changed files with 986 additions and 142 deletions
+8
View File
@@ -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:
+20
View File
@@ -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`;
+32
View File
@@ -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
}
+25
View File
@@ -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)
}
}
+12
View File
@@ -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)
}
+5
View File
@@ -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",
+16
View File
@@ -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` 架构;
+2 -4
View File
@@ -3,19 +3,17 @@ 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.41.0
golang.org/x/crypto v0.52.0
golang.org/x/sys v0.47.0
golang.org/x/term v0.34.0
golang.org/x/term v0.43.0
modernc.org/sqlite v1.38.2
)
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
+4 -8
View File
@@ -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=
@@ -26,8 +22,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
@@ -37,8 +33,8 @@ golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+77 -111
View File
@@ -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)
}
}
+334
View File
@@ -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
}
+127
View File
@@ -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")
}
}
}
+32 -8
View File
@@ -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
+44
View File
@@ -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)
}
}
+8
View File
@@ -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)
}
@@ -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")
+197 -11
View File
@@ -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 <raw url> | 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/IPsecVoWiFi 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
+30
View File
@@ -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
}