mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
Compare commits
2
Commits
296f963885
...
v0.1.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
288e856fdb | ||
|
|
f17d925c4c |
@@ -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:
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func lockServerInstance(databasePath string) (*os.File, error) {
|
||||
// The modem, PC/SC reader, XFRM policies and listener are host resources,
|
||||
// not database resources. Lock per OS user so a diagnostic instance using a
|
||||
// different VOCAT_DATABASE_PATH cannot silently steal the same AT port from
|
||||
// the managed service. Prefer /run because systemd's PrivateTmp would
|
||||
// otherwise hide the managed service's lock from a manually started process.
|
||||
// The UID-specific directory still permits intentionally isolated users to
|
||||
// operate independently; development hosts without writable /run fall back
|
||||
// to TempDir.
|
||||
uid := os.Geteuid()
|
||||
directory := filepath.Join("/run", fmt.Sprintf("vocat-%d", uid))
|
||||
if uid == 0 {
|
||||
directory = "/run/vocat"
|
||||
}
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
directory = os.TempDir()
|
||||
}
|
||||
path := filepath.Join(directory, "vocat-server.lock")
|
||||
fd, err := unix.Open(path, unix.O_CREAT|unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open server instance lock: %w", err)
|
||||
}
|
||||
file := os.NewFile(uintptr(fd), path)
|
||||
if err := unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB); err != nil {
|
||||
_ = file.Close()
|
||||
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
|
||||
return nil, errors.New("another vocat server already controls this host's modem resources")
|
||||
}
|
||||
return nil, fmt.Errorf("lock server instance: %w", err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServerInstanceLockRejectsSecondProcess(t *testing.T) {
|
||||
firstDatabase := filepath.Join(t.TempDir(), "vocat.db")
|
||||
first, err := lockServerInstance(firstDatabase)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer first.Close()
|
||||
secondDatabase := filepath.Join(t.TempDir(), "other.db")
|
||||
second, err := lockServerInstance(secondDatabase)
|
||||
if second != nil {
|
||||
second.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "already controls this host") {
|
||||
t.Fatalf("second lock error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+66
-5
@@ -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",
|
||||
@@ -617,12 +622,18 @@ func configureVoWiFiRuntime(
|
||||
}
|
||||
if deviceConfig.VoWiFiEnabled {
|
||||
if entry, mapErr := mapper.Get(deviceConfig.ID); mapErr == nil {
|
||||
flightContext, cancelFlight := context.WithTimeout(ctx, 10*time.Second)
|
||||
_, flightErr := deviceManager.SetFlight(flightContext, entry.ID, true)
|
||||
cancelFlight()
|
||||
flightErr := protectVoWiFiStartupRadio(ctx, deviceManager, entry.ID)
|
||||
if flightErr != nil {
|
||||
_ = manager.Close(context.Background())
|
||||
return nil, fmt.Errorf("protect device %q before VoWiFi startup: %w", deviceConfig.ID, flightErr)
|
||||
// A modem can be temporarily unavailable while OpenWrt/procd is
|
||||
// restarting the service (notably after loading XFRM modules). Do
|
||||
// not take the Web/API service down with it: the orchestrator below
|
||||
// remains fail-closed and its runtime manager retries until CFUN=4
|
||||
// can be established.
|
||||
logger.Warn(
|
||||
"VoWiFi startup radio protection deferred to automatic retry",
|
||||
"device_id", deviceConfig.ID,
|
||||
"error", flightErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
if _, err := manager.RequestEnabled(deviceConfig.ID, true); err != nil {
|
||||
@@ -634,6 +645,56 @@ func configureVoWiFiRuntime(
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
const (
|
||||
vowifiStartupRadioAttempts = 3
|
||||
vowifiStartupRadioDelay = time.Second
|
||||
)
|
||||
|
||||
type flightModeSetter interface {
|
||||
SetFlight(context.Context, string, bool) (device.FlightResult, error)
|
||||
}
|
||||
|
||||
func protectVoWiFiStartupRadio(ctx context.Context, manager flightModeSetter, physicalID string) error {
|
||||
return protectVoWiFiStartupRadioWithRetry(
|
||||
ctx,
|
||||
manager,
|
||||
physicalID,
|
||||
vowifiStartupRadioAttempts,
|
||||
vowifiStartupRadioDelay,
|
||||
)
|
||||
}
|
||||
|
||||
func protectVoWiFiStartupRadioWithRetry(
|
||||
ctx context.Context,
|
||||
manager flightModeSetter,
|
||||
physicalID string,
|
||||
attempts int,
|
||||
delay time.Duration,
|
||||
) error {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < attempts; attempt++ {
|
||||
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
_, lastErr = manager.SetFlight(flightContext, physicalID, true)
|
||||
cancel()
|
||||
if lastErr == nil {
|
||||
return nil
|
||||
}
|
||||
if attempt+1 == attempts {
|
||||
break
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
return errors.Join(lastErr, ctx.Err())
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
type vowifiDeviceAdapter interface {
|
||||
vowifi.SIMIdentityReader
|
||||
vowifi.AKAProvider
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/device"
|
||||
)
|
||||
|
||||
type startupFlightSetter struct {
|
||||
errors []error
|
||||
calls int
|
||||
id string
|
||||
}
|
||||
|
||||
func (setter *startupFlightSetter) SetFlight(
|
||||
_ context.Context,
|
||||
id string,
|
||||
enabled bool,
|
||||
) (device.FlightResult, error) {
|
||||
setter.calls++
|
||||
setter.id = id
|
||||
if !enabled {
|
||||
return device.FlightResult{}, errors.New("expected flight mode to be enabled")
|
||||
}
|
||||
if setter.calls <= len(setter.errors) {
|
||||
return device.FlightResult{}, setter.errors[setter.calls-1]
|
||||
}
|
||||
return device.FlightResult{CurrentMode: 4, FlightMode: true, RadioOff: true}, nil
|
||||
}
|
||||
|
||||
func TestProtectVoWiFiStartupRadioRetriesTransientFailure(t *testing.T) {
|
||||
transient := errors.New("modem is reopening")
|
||||
setter := &startupFlightSetter{errors: []error{transient, transient}}
|
||||
if err := protectVoWiFiStartupRadioWithRetry(
|
||||
context.Background(), setter, "quectel-1", 3, 0,
|
||||
); err != nil {
|
||||
t.Fatalf("protect startup radio: %v", err)
|
||||
}
|
||||
if setter.calls != 3 || setter.id != "quectel-1" {
|
||||
t.Fatalf("SetFlight calls = %d, id = %q", setter.calls, setter.id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectVoWiFiStartupRadioReturnsLastFailure(t *testing.T) {
|
||||
first := errors.New("first")
|
||||
last := errors.New("last")
|
||||
setter := &startupFlightSetter{errors: []error{first, last}}
|
||||
err := protectVoWiFiStartupRadioWithRetry(
|
||||
context.Background(), setter, "quectel-1", 2, 0,
|
||||
)
|
||||
if !errors.Is(err, last) || setter.calls != 2 {
|
||||
t.Fatalf("protect startup radio = %v after %d calls", err, setter.calls)
|
||||
}
|
||||
}
|
||||
@@ -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` 架构;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
+77
-111
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
+212
-11
@@ -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/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 ----------------------------------------------------------
|
||||
@@ -218,6 +337,8 @@ TimeoutStartSec=30s
|
||||
# HTTP, VoWiFi, and modem cleanup have bounded shutdown contexts totalling up
|
||||
# to 30 seconds. Leave a small margin before systemd resorts to SIGKILL.
|
||||
TimeoutStopSec=40s
|
||||
RuntimeDirectory=vocat
|
||||
RuntimeDirectoryMode=0755
|
||||
|
||||
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW
|
||||
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW
|
||||
@@ -246,11 +367,86 @@ 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
|
||||
# Modems may need several seconds to release and reopen their AT
|
||||
# port after procd stops the previous process. Require consecutive
|
||||
# healthy observations so a short-lived respawn is not mistaken for
|
||||
# a successful upgrade.
|
||||
local attempt stable
|
||||
stable=0
|
||||
for attempt in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30; do
|
||||
sleep 1
|
||||
if "$OPENWRT_INIT_PATH" running; then
|
||||
stable=$((stable + 1))
|
||||
if [ "$stable" -ge 3 ]; then
|
||||
rm -f "${BINARY_PATH}.bak"
|
||||
return
|
||||
fi
|
||||
else
|
||||
stable=0
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ -e "${BINARY_PATH}.bak" ]; then
|
||||
cp -a "${BINARY_PATH}.bak" "$BINARY_PATH"
|
||||
"$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 +457,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 +482,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user