Merge remote-tracking branch 'origin/master' into feat/admin-credentials-pr-limit

# Conflicts:
#	cmd/vocat/menu.go
#	internal/auth/service.go
This commit is contained in:
MengMengCode
2026-08-14 21:43:57 +08:00
18 changed files with 838 additions and 35 deletions
+31
View File
@@ -0,0 +1,31 @@
package main
import (
"os"
"strings"
"testing"
)
func TestInstallerValidatesDatabaseBeforeReplacingBinary(t *testing.T) {
scriptBytes, err := os.ReadFile("../../scripts/install.sh")
if err != nil {
t.Fatal(err)
}
script := string(scriptBytes)
mainStart := strings.LastIndex(script, "# --- Main ")
if mainStart < 0 {
t.Fatal("installer main section not found")
}
main := script[mainStart:]
validateAt := strings.Index(main, `bootstrap_admin "${VOCAT_TMP}/vocat"`)
installAt := strings.Index(main, "install_binary")
if validateAt < 0 {
t.Fatal("installer does not validate the database with the downloaded binary")
}
if installAt < 0 {
t.Fatal("installer does not install the downloaded binary")
}
if validateAt > installAt {
t.Fatal("installer replaces the current binary before validating database compatibility")
}
}
+118
View File
@@ -220,6 +220,124 @@ func TestMigration8DefaultsExistingDevicesToPCIeType(t *testing.T) {
}
}
func TestMigration19AcceptsDevelopmentDatabaseAndPreservesCardData(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "development-schema.db")
raw, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
for version := 1; version <= 16; version++ {
for _, statement := range migrationStatements(version) {
if _, err := raw.ExecContext(ctx, statement); err != nil {
t.Fatalf("create v%d schema: %v", version, err)
}
}
}
if _, err := raw.ExecContext(ctx, `
INSERT INTO devices (id, name, created_at, updated_at)
VALUES ('ec20-1', 'EC20', 100, 100);
INSERT INTO card_policies (
iccid, network_enabled, vowifi_enabled, airplane_enabled,
apn, ip_version, source, created_at, updated_at, custom_phone_number
) VALUES (
'8900000000000000019', 0, 1, 1,
'ims', 'IPV4V6', 'user', 100, 100, '447700900019'
);
INSERT INTO card_apn_profiles (
iccid, apn, ip_version, created_at, updated_at,
username, password, proxy, mcc, mnc, roaming_ip_version, auth_type
) VALUES (
'8900000000000000019', 'mobile.example', 'IPV4V6', 100, 100,
'user', 'secret', '', '234', '10', 'IP', 'PAP'
);
PRAGMA user_version = 16;
`); err != nil {
t.Fatal(err)
}
if err := raw.Close(); err != nil {
t.Fatal(err)
}
database := openTestStore(t, path)
policy, err := database.CardPolicy(ctx, "8900000000000000019")
if err != nil {
t.Fatal(err)
}
if !policy.VoWiFiEnabled || !policy.AirplaneEnabled || policy.CustomPhoneNumber != "447700900019" {
t.Fatalf("migrated card policy = %#v", policy)
}
profiles, err := database.ListCardAPNProfiles(ctx, "8900000000000000019")
if err != nil {
t.Fatal(err)
}
if len(profiles) != 1 || profiles[0].APN != "mobile.example" || profiles[0].Username != "user" || profiles[0].AuthType != "PAP" {
t.Fatalf("migrated APN profiles = %#v", profiles)
}
var version int
if err := database.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version); err != nil {
t.Fatal(err)
}
if version != 19 {
t.Fatalf("schema version = %d, want 19", version)
}
for _, column := range []string{
"ims_apn", "ims_private_identity", "ims_public_identity", "ims_sms_center",
"ims_transport", "ims_allow_imsi_derived_identity", "vowifi_eap_method",
"vowifi_allow_sha1", "vowifi_use_modp1024",
} {
var count int
if err := database.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM pragma_table_info('devices') WHERE name = ?
`, column).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("migration 19 column %q count = %d", column, count)
}
}
}
func TestMigration19AcceptsDevelopmentColumnsAlreadyPresent(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "development-columns.db")
raw, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
for version := 1; version <= 18; version++ {
for _, statement := range migrationStatements(version) {
if _, err := raw.ExecContext(ctx, statement); err != nil {
t.Fatalf("create v%d schema: %v", version, err)
}
}
}
// The development build added these columns while still reporting schema
// 18. Migration 19 must treat that layout as compatible rather than fail on
// the first duplicate ALTER TABLE statement.
for _, statement := range migrationStatements(19) {
if _, err := raw.ExecContext(ctx, statement); err != nil {
t.Fatalf("create development column: %v", err)
}
}
if _, err := raw.ExecContext(ctx, `PRAGMA user_version = 18`); err != nil {
t.Fatal(err)
}
if err := raw.Close(); err != nil {
t.Fatal(err)
}
database := openTestStore(t, path)
var version int
if err := database.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version); err != nil {
t.Fatal(err)
}
if version != 19 {
t.Fatalf("schema version = %d, want 19", version)
}
}
func TestMigration4PreservesIMSRedeliveryAndUsesReceiptTime(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "ims-redelivery.db")
+117
View File
@@ -264,6 +264,123 @@ func migrationStatements(version int) []string {
return []string{
`ALTER TABLE devices ADD COLUMN sim_pin TEXT NOT NULL DEFAULT ''`,
}
case 17:
// Some development builds recorded automatic-task support in an older
// migration. Recreate the objects idempotently so databases from either
// history converge before later migrations run.
return []string{
`CREATE TABLE IF NOT EXISTS automatic_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
device_id TEXT NOT NULL,
profile_iccid TEXT NOT NULL,
profile_aid TEXT NOT NULL DEFAULT '',
task_type TEXT NOT NULL CHECK (task_type IN ('sms', 'call', 'public_ip')),
environment TEXT NOT NULL CHECK (environment IN ('vowifi', 'cellular')),
interval_days INTEGER NOT NULL CHECK (interval_days BETWEEN 1 AND 365),
start_date TEXT NOT NULL,
run_time TEXT NOT NULL,
timezone TEXT NOT NULL DEFAULT 'Local',
payload_json TEXT NOT NULL DEFAULT '{}',
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count BETWEEN 0 AND 10),
notify INTEGER NOT NULL DEFAULT 0 CHECK (notify IN (0, 1)),
next_run_at INTEGER NOT NULL,
last_run_at INTEGER NOT NULL DEFAULT 0,
last_status TEXT NOT NULL DEFAULT '',
last_error TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS automatic_tasks_due_idx ON automatic_tasks(enabled, next_run_at, id)`,
`CREATE INDEX IF NOT EXISTS automatic_tasks_device_idx ON automatic_tasks(device_id, next_run_at, id)`,
`CREATE TABLE IF NOT EXISTS automatic_task_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
device_id TEXT NOT NULL,
scheduled_at INTEGER NOT NULL,
started_at INTEGER NOT NULL DEFAULT 0,
finished_at INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'success', 'failed')),
attempts INTEGER NOT NULL DEFAULT 0,
output TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (task_id) REFERENCES automatic_tasks(id) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS automatic_task_runs_task_idx ON automatic_task_runs(task_id, id DESC)`,
`CREATE INDEX IF NOT EXISTS automatic_task_runs_status_idx ON automatic_task_runs(status, id)`,
}
case 18:
// A short-lived schema lineage kept the original card-policy CHECK,
// which rejected the supported VoWiFi + airplane-mode state. Rebuild
// both related tables so all released and development databases converge
// without dropping policies or custom APNs.
return []string{
`ALTER TABLE card_apn_profiles RENAME TO card_apn_profiles_v17`,
`ALTER TABLE card_policies RENAME TO card_policies_v17`,
`CREATE TABLE card_policies (
iccid TEXT PRIMARY KEY,
network_enabled INTEGER NOT NULL DEFAULT 0 CHECK (network_enabled IN (0, 1)),
vowifi_enabled INTEGER NOT NULL DEFAULT 0 CHECK (vowifi_enabled IN (0, 1)),
airplane_enabled INTEGER NOT NULL DEFAULT 0 CHECK (airplane_enabled IN (0, 1)),
apn TEXT NOT NULL DEFAULT '',
ip_version TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
custom_phone_number TEXT NOT NULL DEFAULT ''
)`,
`INSERT INTO card_policies (
iccid, network_enabled, vowifi_enabled, airplane_enabled,
apn, ip_version, source, created_at, updated_at, custom_phone_number
) SELECT
iccid, network_enabled, vowifi_enabled, airplane_enabled,
apn, ip_version, source, created_at, updated_at, custom_phone_number
FROM card_policies_v17`,
`CREATE TABLE card_apn_profiles_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
iccid TEXT NOT NULL,
apn TEXT NOT NULL,
ip_version TEXT NOT NULL DEFAULT 'IPV4V6' CHECK (ip_version IN ('IP', 'IPV6', 'IPV4V6')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
username TEXT NOT NULL DEFAULT '',
password TEXT NOT NULL DEFAULT '',
proxy TEXT NOT NULL DEFAULT '',
mcc TEXT NOT NULL DEFAULT '',
mnc TEXT NOT NULL DEFAULT '',
roaming_ip_version TEXT NOT NULL DEFAULT 'IP' CHECK (roaming_ip_version IN ('IP', 'IPV6', 'IPV4V6')),
auth_type TEXT NOT NULL DEFAULT 'NONE' CHECK (auth_type IN ('NONE', 'PAP', 'CHAP', 'PAP_OR_CHAP')),
UNIQUE (iccid, apn, ip_version),
FOREIGN KEY (iccid) REFERENCES card_policies(iccid) ON DELETE CASCADE
)`,
`INSERT INTO card_apn_profiles_new
SELECT id, iccid, apn, ip_version, created_at, updated_at,
username, password, proxy, mcc, mnc, roaming_ip_version, auth_type
FROM card_apn_profiles_v17`,
`DROP TABLE card_apn_profiles_v17`,
`DROP TABLE card_policies_v17`,
`ALTER TABLE card_apn_profiles_new RENAME TO card_apn_profiles`,
`CREATE INDEX card_apn_profiles_iccid_idx ON card_apn_profiles(iccid, id)`,
}
case 19:
// Compatibility columns written by the Qualcomm/IMS development build.
// The stable server may leave them unused, but retaining them makes a
// database created by that build safely readable after an upgrade.
return []string{
`ALTER TABLE devices ADD COLUMN ims_apn TEXT NOT NULL DEFAULT 'ims'`,
`ALTER TABLE devices ADD COLUMN ims_private_identity TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE devices ADD COLUMN ims_public_identity TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE devices ADD COLUMN ims_sms_center TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE devices ADD COLUMN ims_transport TEXT NOT NULL DEFAULT 'tcp'`,
`ALTER TABLE devices ADD COLUMN ims_allow_imsi_derived_identity INTEGER NOT NULL DEFAULT 1 CHECK (ims_allow_imsi_derived_identity IN (0, 1))`,
`ALTER TABLE devices ADD COLUMN vowifi_eap_method TEXT NOT NULL DEFAULT 'aka'`,
`ALTER TABLE devices ADD COLUMN vowifi_allow_sha1 INTEGER NOT NULL DEFAULT 0 CHECK (vowifi_allow_sha1 IN (0, 1))`,
`ALTER TABLE devices ADD COLUMN vowifi_use_modp1024 INTEGER NOT NULL DEFAULT 0 CHECK (vowifi_use_modp1024 IN (0, 1))`,
}
default:
return nil
}
+3 -2
View File
@@ -13,7 +13,7 @@ import (
_ "modernc.org/sqlite"
)
const schemaVersion = 16
const schemaVersion = 19
var ErrNotFound = errors.New("store: not found")
@@ -123,7 +123,8 @@ func migrate(ctx context.Context, db *sql.DB) error {
duplicateAdditiveColumn := (nextVersion == 7 && strings.Contains(statement, "ADD COLUMN modem_imei")) ||
(nextVersion == 8 && strings.Contains(statement, "ADD COLUMN device_type")) ||
(nextVersion == 14 && strings.Contains(statement, "ADD COLUMN")) ||
(nextVersion == 16 && strings.Contains(statement, "ADD COLUMN sim_pin"))
(nextVersion == 16 && strings.Contains(statement, "ADD COLUMN sim_pin")) ||
(nextVersion == 19 && strings.Contains(statement, "ADD COLUMN"))
if duplicateAdditiveColumn && strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
continue
}
+60
View File
@@ -0,0 +1,60 @@
package vowifi
import (
"fmt"
"strings"
)
const att310280EPDG = "epdg.epc.att.net"
// AssignedRoutePLMN returns a narrowly matched ePDG route PLMN without
// changing the subscription PLMN used for AKA identities. Some multi-profile
// and MVNO SIMs authenticate against their own HPLMN but use a host network's
// VoWiFi access gateway.
func AssignedRoutePLMN(iccid, imsi string) (string, string, bool) {
iccid = strings.TrimSpace(iccid)
imsi = strings.TrimSpace(imsi)
switch {
case strings.HasPrefix(iccid, "894416") && strings.HasPrefix(imsi, "204047"):
// XeSIM/Lebara: keep 204/04 for AKA and use Vodafone UK's ePDG.
return "234", "15", true
case strings.HasPrefix(iccid, "894430") && strings.HasPrefix(imsi, "23433"):
// CTExcel UK: keep 234/33 for AKA and use the EE UK ePDG used by
// the initial VoWiFi provisioning path.
return "234", "30", true
default:
return "", "", false
}
}
// IsATT310280 reports whether the live subscription is on AT&T's three-digit
// 310/280 PLMN. It is shared by SWu and IMS so the carrier exception cannot
// drift between protocol layers.
func IsATT310280(identity SIMIdentity) bool {
mcc := strings.TrimSpace(identity.HomeMCC)
mnc := strings.TrimLeft(strings.TrimSpace(identity.HomeMNC), "0")
imsi := strings.TrimSpace(identity.IMSI)
return mcc == "310" && mnc == "280" && strings.HasPrefix(imsi, "310280")
}
func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
if strings.TrimSpace(identity.EPDG) != "" {
return identity
}
if routeMCC, routeMNC, ok := AssignedRoutePLMN(identity.ICCID, identity.IMSI); ok {
identity.EPDG = standardEPDGHostname(routeMCC, routeMNC)
}
return identity
}
func standardEPDGHostname(mcc, mnc string) string {
mnc = strings.TrimSpace(mnc)
for len(mnc) < 3 {
mnc = "0" + mnc
}
return fmt.Sprintf(
"epdg.epc.mnc%s.mcc%s.pub.3gppnetwork.org",
mnc,
strings.TrimSpace(mcc),
)
}
+56
View File
@@ -0,0 +1,56 @@
package vowifi
import "testing"
func TestAssignedRoutePLMNUsesNarrowCardAndSubscriptionMatches(t *testing.T) {
tests := []struct {
name string
iccid string
imsi string
wantMCC string
wantMNC string
wantAssigned bool
}{
{name: "XeSIM Lebara route", iccid: "89441600001001576265", imsi: "204047666157626", wantMCC: "234", wantMNC: "15", wantAssigned: true},
{name: "CTExcel initial route", iccid: "8944303773524055208", imsi: "234336570712415", wantMCC: "234", wantMNC: "30", wantAssigned: true},
{name: "XeSIM ICCID without matching subscription", iccid: "89441600001001576265", imsi: "204041666157626"},
{name: "similar ICCID must not match", iccid: "89441000001001576265", imsi: "204047666157626"},
{name: "generic EE SIM must not match CTExcel", iccid: "8944110000000000000", imsi: "234336570712415"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
mcc, mnc, assigned := AssignedRoutePLMN(test.iccid, test.imsi)
if mcc != test.wantMCC || mnc != test.wantMNC || assigned != test.wantAssigned {
t.Fatalf("AssignedRoutePLMN() = %q/%q,%v, want %q/%q,%v", mcc, mnc, assigned, test.wantMCC, test.wantMNC, test.wantAssigned)
}
})
}
}
func TestApplyAssignedCarrierRoutePreservesAuthenticationPLMN(t *testing.T) {
identity := applyAssignedCarrierRoute(SIMIdentity{
ICCID: "8944303773524055208", IMSI: "234336570712415",
HomeMCC: "234", HomeMNC: "33",
})
if identity.HomeMCC != "234" || identity.HomeMNC != "33" {
t.Fatalf("authentication PLMN = %s/%s, want 234/33", identity.HomeMCC, identity.HomeMNC)
}
if identity.EPDG != "epdg.epc.mnc030.mcc234.pub.3gppnetwork.org" {
t.Fatalf("route ePDG = %q", identity.EPDG)
}
}
func TestIsATT310280RequiresMatchingPLMNAndIMSI(t *testing.T) {
if !IsATT310280(SIMIdentity{IMSI: "310280229187733", HomeMCC: "310", HomeMNC: "280"}) {
t.Fatal("AT&T 310/280 identity was not recognized")
}
for _, identity := range []SIMIdentity{
{IMSI: "310410229187733", HomeMCC: "310", HomeMNC: "280"},
{IMSI: "310280229187733", HomeMCC: "310", HomeMNC: "28"},
{IMSI: "310280229187733", HomeMCC: "311", HomeMNC: "280"},
} {
if IsATT310280(identity) {
t.Fatalf("unrelated identity matched AT&T 310/280: %#v", identity)
}
}
}
+135 -10
View File
@@ -97,9 +97,10 @@ type ec20RadioCheckpoint struct {
}
var (
_ SIMIdentityReader = (*EC20Adapter)(nil)
_ AKAProvider = (*EC20Adapter)(nil)
_ RadioController = (*EC20Adapter)(nil)
_ SIMIdentityReader = (*EC20Adapter)(nil)
_ AKAProvider = (*EC20Adapter)(nil)
_ PreferredAKAProvider = (*EC20Adapter)(nil)
_ RadioController = (*EC20Adapter)(nil)
)
func NewEC20Adapter(
@@ -172,6 +173,7 @@ func (adapter *EC20Adapter) ReadIdentity(
HomeMCC: homeMCC,
HomeMNC: homeMNC,
}
identity = applyAssignedCarrierRoute(identity)
adapter.mu.Lock()
adapter.bindings[iccid] = ec20SIMBinding{
deviceID: deviceID,
@@ -208,6 +210,11 @@ func (adapter *EC20Adapter) readHomePLMN(
iccid string,
imsi string,
) (string, string, error) {
// AT&T 310/280 is a three-digit MNC. Prefer the assigned subscription
// prefix when EF_AD is stale or ambiguous after a profile switch.
if strings.HasPrefix(strings.TrimSpace(imsi), "310280") {
return "310", "280", nil
}
mncLength, efErr := adapter.readExplicitMNCLength(ctx, deviceID)
if efErr == nil {
if len(imsi) < 3+mncLength {
@@ -238,9 +245,10 @@ func assignedHomePLMN(imsi string) (mcc, mnc string, ok bool) {
prefix string
mncLength int
}{
{prefix: "20404", mncLength: 2}, // Vodafone NL core; some Lebara subscriptions.
{prefix: "23415", mncLength: 2}, // Vodafone UK.
{prefix: "23487", mncLength: 2}, // Lebara Mobile UK.
{prefix: "20404", mncLength: 2}, // Vodafone NL core; some Lebara subscriptions.
{prefix: "23415", mncLength: 2}, // Vodafone UK.
{prefix: "23487", mncLength: 2}, // Lebara Mobile UK.
{prefix: "310280", mncLength: 3}, // AT&T / RedPocket GSMA.
}
for _, assignment := range assignments {
if strings.HasPrefix(imsi, assignment.prefix) {
@@ -391,11 +399,46 @@ func (adapter *EC20Adapter) Authenticate(
ctx context.Context,
identity SIMIdentity,
challenge AKAChallenge,
) (AKAResult, error) {
return adapter.authenticateWithApplication(ctx, identity, challenge, "")
}
func (adapter *EC20Adapter) AuthenticateWithPreference(
ctx context.Context,
identity SIMIdentity,
challenge AKAChallenge,
preference string,
) (AKAResult, error) {
return adapter.authenticateWithApplication(ctx, identity, challenge, preference)
}
func (adapter *EC20Adapter) authenticateWithApplication(
ctx context.Context,
identity SIMIdentity,
challenge AKAChallenge,
preference string,
) (AKAResult, error) {
binding, err := adapter.bindingFor(identity)
if err != nil {
return AKAResult{}, err
}
if strings.EqualFold(strings.TrimSpace(preference), "isim_strict") && binding.application != "ISIM" {
aid, application, err := adapter.discoverPreferredAKAApplication(
ctx,
binding.deviceID,
isimAIDPrefix,
"ISIM",
)
if err != nil {
return AKAResult{}, err
}
binding.aid = aid
binding.application = application
binding.basicChannel = false
adapter.mu.Lock()
adapter.bindings[binding.iccid] = binding
adapter.mu.Unlock()
}
if binding.aid == "" {
if _, err := adapter.CheckReady(ctx, identity); err != nil {
return AKAResult{}, err
@@ -405,6 +448,14 @@ func (adapter *EC20Adapter) Authenticate(
return AKAResult{}, err
}
}
if strings.EqualFold(strings.TrimSpace(preference), "isim_strict") && binding.application != "ISIM" {
return AKAResult{}, fmt.Errorf(
"%w: ISIM strict requested, selected %s (%s)",
ErrEC20ApplicationAbsent,
binding.application,
binding.aid,
)
}
if err := adapter.verifyLiveICCID(ctx, binding); err != nil {
return AKAResult{}, err
}
@@ -907,6 +958,28 @@ func (adapter *EC20Adapter) discoverAKAApplication(
return usimAIDPrefix, "USIM", nil
}
func (adapter *EC20Adapter) discoverPreferredAKAApplication(
ctx context.Context,
deviceID string,
aidPrefix string,
application string,
) (string, string, error) {
response, err := adapter.execute(ctx, deviceID, "AT+CUAD")
if err == nil {
data, parseErr := parseCUADData(response)
if parseErr == nil {
for _, candidate := range collectApplicationAIDs(data) {
if strings.HasPrefix(candidate, aidPrefix) {
return candidate, application, nil
}
}
}
}
// AT+CUAD is optional. Returning the standard AID prefix still lets CCHO
// perform the authoritative application probe on older EC20 firmware.
return aidPrefix, application, nil
}
func (adapter *EC20Adapter) openLogicalChannel(
ctx context.Context,
deviceID string,
@@ -1146,12 +1219,27 @@ func parseCRSMData(response modem.Response) ([]byte, error) {
}
func parseCUADData(response modem.Response) ([]byte, error) {
fields := parseCSV(valueAfterATPrefix(response, "+CUAD:"))
if len(fields) == 0 {
// EC20 firmware may split the BER-TLV stream across adjacent quoted chunks
// and continuation lines. Concatenating every hex fragment prevents an ISIM
// AID after a USIM entry from being silently discarded.
var encoded strings.Builder
collect := false
for _, line := range response.Lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(strings.ToUpper(line), "+CUAD:") {
collect = true
line = strings.TrimSpace(line[len("+CUAD:"):])
} else if !collect {
continue
}
for _, fragment := range quotedHexFragments(line) {
encoded.WriteString(fragment)
}
}
if encoded.Len() == 0 {
return nil, errors.New("CUAD response has no data")
}
value := fields[len(fields)-1]
data, err := hex.DecodeString(strings.Trim(value, `"`))
data, err := hex.DecodeString(encoded.String())
if err != nil || len(data) == 0 {
return nil, errors.New("CUAD response data is invalid")
}
@@ -1161,11 +1249,48 @@ func parseCUADData(response modem.Response) ([]byte, error) {
return data, nil
}
func quotedHexFragments(line string) []string {
var fragments []string
for {
start := strings.IndexByte(line, '"')
if start < 0 {
break
}
line = line[start+1:]
end := strings.IndexByte(line, '"')
if end < 0 {
break
}
fragment := strings.ToUpper(strings.TrimSpace(line[:end]))
line = line[end+1:]
if fragment == "" || len(fragment)%2 != 0 {
continue
}
valid := true
for _, character := range fragment {
if (character < '0' || character > '9') && (character < 'A' || character > 'F') {
valid = false
break
}
}
if valid {
fragments = append(fragments, fragment)
}
}
return fragments
}
func collectApplicationAIDs(data []byte) []string {
var result []string
var walk func([]byte)
walk = func(value []byte) {
for len(value) > 0 {
for len(value) > 0 && value[0] == 0xff {
value = value[1:]
}
if len(value) == 0 {
return
}
tag, constructed, body, consumed, err := decodeBERTLV(value)
if err != nil || consumed == 0 {
return
+94
View File
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"errors"
"fmt"
"reflect"
"strings"
"sync"
"testing"
@@ -421,6 +422,7 @@ func TestAssignedHomePLMNIncludesLebaraUKCores(t *testing.T) {
"204040123456789": "204/04",
"234150123456789": "234/15",
"234870123456789": "234/87",
"310280229187733": "310/280",
}
for imsi, want := range tests {
mcc, mnc, ok := assignedHomePLMN(imsi)
@@ -430,6 +432,23 @@ func TestAssignedHomePLMNIncludesLebaraUKCores(t *testing.T) {
}
}
func TestEC20AdapterTreatsATT310280AsThreeDigitMNC(t *testing.T) {
t.Parallel()
transcript := &ec20Transcript{t: t, steps: identityTranscriptStepsWithoutEFAD("310280229187733")}
adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{})
if err != nil {
t.Fatal(err)
}
identity, err := adapter.ReadIdentity(context.Background(), "ec20-1")
if err != nil {
t.Fatalf("ReadIdentity: %v", err)
}
if identity.HomeMCC != "310" || identity.HomeMNC != "280" {
t.Fatalf("home PLMN = %s/%s, want 310/280", identity.HomeMCC, identity.HomeMNC)
}
transcript.assertDone()
}
func TestEC20AdapterRadioTransactionRestoresCFUNAndPDPContexts(
t *testing.T,
) {
@@ -598,3 +617,78 @@ func synchronizationFailureUSIMResponse() []byte {
raw = append(raw, auts...)
return append(raw, 0x90, 0x00)
}
func TestCollectApplicationAIDsSkipsCUADPadding(t *testing.T) {
t.Parallel()
response := modem.Response{Lines: []string{
`+CUAD: "61184F10A0000000871002FFFFFFFF890302000050045553494DFFFFFFFFFFFFFFFFFFFFFFFF""61184F10A0000000871004FFFFFFFF890302000050044953494DFFFFFFFFFFFFFFFFFFFFFFFF"`,
`"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"`,
}}
data, err := parseCUADData(response)
if err != nil {
t.Fatal(err)
}
aids := collectApplicationAIDs(data)
want := []string{
"A0000000871002FFFFFFFF8903020000",
"A0000000871004FFFFFFFF8903020000",
}
if !reflect.DeepEqual(aids, want) {
t.Fatalf("AIDs = %v, want %v", aids, want)
}
}
func TestEC20AdapterISIMStrictUsesCUADFullAID(t *testing.T) {
var challenge AKAChallenge
for index := range challenge.RAND {
challenge.RAND[index] = byte(index)
challenge.AUTN[index] = byte(0xf0 + index)
}
authAPDU := buildUSIMAuthenticateAPDU(challenge)
authCommand := fmt.Sprintf(
`AT+CGLA=1,%d,"%s"`,
len(authAPDU)*2,
strings.ToUpper(hex.EncodeToString(authAPDU)),
)
encodedResponse := strings.ToUpper(hex.EncodeToString(successfulUSIMResponse()))
fullISIM := "A0000000871004FFFFFFFF8903020000"
cuad := `61184F10A0000000871002FFFFFFFF890302000050045553494D61184F10A0000000871004FFFFFFFF890302000050044953494D`
transcript := &ec20Transcript{
t: t,
steps: []ec20TranscriptStep{
{command: "AT+CPIN?", lines: []string{"+CPIN: READY"}},
{command: "AT+CIMI", lines: []string{"310280229187733"}},
{command: "AT+CCID", lines: []string{"+CCID: 89012804332291663965"}},
{command: "AT+CGSN", lines: []string{"863212060022487"}},
{command: "AT+CUAD", lines: []string{`+CUAD: "` + cuad + `"`}},
{command: "AT+CCID", lines: []string{"+CCID: 89012804332291663965"}},
{command: `AT+CCHO="` + fullISIM + `"`, lines: []string{"+CCHO: 1"}},
{
command: authCommand,
sensitive: true,
lines: []string{fmt.Sprintf(
`+CGLA: %d,"%s"`,
len(encodedResponse),
encodedResponse,
)},
},
{command: "AT+CCHC=1"},
},
}
adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{})
if err != nil {
t.Fatal(err)
}
identity, err := adapter.ReadIdentity(context.Background(), "ec20-1")
if err != nil {
t.Fatalf("ReadIdentity: %v", err)
}
result, err := adapter.AuthenticateWithPreference(context.Background(), identity, challenge, "isim_strict")
if err != nil {
t.Fatalf("AuthenticateWithPreference: %v", err)
}
if !bytes.Equal(result.RES, []byte{1, 2, 3, 4, 5, 6, 7, 8}) {
t.Fatalf("RES = %x", result.RES)
}
transcript.assertDone()
}
+12 -2
View File
@@ -168,6 +168,7 @@ func authenticateAKA(
provider vowifi.AKAProvider,
identity vowifi.SIMIdentity,
challenge digestChallenge,
preference string,
) (akaMaterial, error) {
nonce, err := decodeAKANonce(challenge.Nonce)
if err != nil {
@@ -178,9 +179,18 @@ func authenticateAKA(
var akaChallenge vowifi.AKAChallenge
copy(akaChallenge.RAND[:], nonce[:16])
copy(akaChallenge.AUTN[:], nonce[16:32])
result, err := provider.Authenticate(ctx, identity, akaChallenge)
var result vowifi.AKAResult
if preferred, ok := provider.(vowifi.PreferredAKAProvider); ok && strings.TrimSpace(preference) != "" {
result, err = preferred.AuthenticateWithPreference(ctx, identity, akaChallenge, preference)
} else {
result, err = provider.Authenticate(ctx, identity, akaChallenge)
}
if err != nil {
return akaMaterial{}, fmt.Errorf("ims: USIM AKA authentication failed: %w", err)
application := "USIM"
if strings.EqualFold(strings.TrimSpace(preference), "isim_strict") {
application = "ISIM"
}
return akaMaterial{}, fmt.Errorf("ims: %s AKA authentication failed: %w", application, err)
}
if result.SynchronizationFailure || len(result.AUTS) > 0 {
if !result.SynchronizationFailure || len(result.AUTS) != 14 {
+37
View File
@@ -16,6 +16,21 @@ type recordingAKA struct {
challenges []vowifi.AKAChallenge
}
type recordingPreferredAKA struct {
recordingAKA
preference string
}
func (aka *recordingPreferredAKA) AuthenticateWithPreference(
ctx context.Context,
identity vowifi.SIMIdentity,
challenge vowifi.AKAChallenge,
preference string,
) (vowifi.AKAResult, error) {
aka.preference = preference
return aka.Authenticate(ctx, identity, challenge)
}
func (aka *recordingAKA) CheckReady(context.Context, vowifi.SIMIdentity) (vowifi.AKAEvidence, error) {
return vowifi.AKAEvidence{Ready: true, Application: "usim"}, nil
}
@@ -60,6 +75,7 @@ func TestAuthenticateAKAMapsNonceToTypedChallenge(t *testing.T) {
aka,
vowifi.SIMIdentity{IMSI: "001010123456789"},
digestChallenge{Nonce: base64.StdEncoding.EncodeToString(nonceBytes)},
"",
)
if err != nil {
t.Fatalf("authenticateAKA() error = %v", err)
@@ -93,6 +109,7 @@ func TestAuthenticateAKAReturnsSynchronizationEvidence(t *testing.T) {
aka,
vowifi.SIMIdentity{},
digestChallenge{Nonce: nonce},
"",
)
if err != nil {
t.Fatalf("authenticateAKA() error = %v", err)
@@ -102,6 +119,26 @@ func TestAuthenticateAKAReturnsSynchronizationEvidence(t *testing.T) {
}
}
func TestAuthenticateAKAUsesPreferredApplicationWhenSupported(t *testing.T) {
nonce := base64.StdEncoding.EncodeToString(make([]byte, 32))
aka := &recordingPreferredAKA{recordingAKA: recordingAKA{
result: vowifi.AKAResult{RES: []byte{1, 2, 3, 4}},
}}
_, err := authenticateAKA(
context.Background(),
aka,
vowifi.SIMIdentity{IMSI: "310280229187733"},
digestChallenge{Nonce: nonce},
"isim_strict",
)
if err != nil {
t.Fatalf("authenticateAKA() error = %v", err)
}
if aka.preference != "isim_strict" {
t.Fatalf("preference = %q, want isim_strict", aka.preference)
}
}
func TestBuildDigestAuthorizationCarriesAUTSWithEmptyResponse(t *testing.T) {
authorization := buildDigestAuthorization(
digestChallenge{
+56 -5
View File
@@ -271,13 +271,22 @@ func deriveIdentities(identity vowifi.SIMIdentity, config Config) (identitySet,
mnc = "0" + mnc
}
domain := fmt.Sprintf("ims.mnc%s.mcc%s.3gppnetwork.org", mnc, mcc)
privateDomain := domain
publicDomain := domain
if vowifi.IsATT310280(identity) {
// AT&T provisions the IMPI and IMPU in its ISIM domains rather than
// the generic 3GPP PLMN IMS domain.
domain = "one.att.net"
privateDomain = "private.att.net"
publicDomain = "one.att.net"
}
privateIdentity := config.PrivateIdentity
if privateIdentity == "" {
privateIdentity = imsi + "@" + domain
privateIdentity = imsi + "@" + privateDomain
}
publicIdentity := config.PublicIdentity
if publicIdentity == "" {
publicIdentity = "sip:" + imsi + "@" + domain
publicIdentity = "sip:" + imsi + "@" + publicDomain
}
if strings.ContainsAny(privateIdentity+publicIdentity, "\r\n") ||
!strings.Contains(privateIdentity, "@") ||
@@ -536,6 +545,9 @@ func newSession(
}
protectedClientPort := provider.config.ProtectedClientPort
protectedServerPort := provider.config.ProtectedServerPort
if vowifi.IsATT310280(request.Identity) && protectedServerPort == 0 {
protectedServerPort = 6000
}
if securityEncryptionForIdentity(request.Identity) == "null" {
if protectedClientPort == 0 {
protectedClientPort = 5062
@@ -554,6 +566,10 @@ func newSession(
return nil, err
}
proposal.encryption = securityEncryptionForIdentity(request.Identity)
if vowifi.IsATT310280(request.Identity) {
proposal.integrityAlgorithms = []string{"hmac-sha-1-96"}
proposal.encryptionAlgorithmsList = []string{"aes-cbc"}
}
session.securityProposal = proposal
protectedTCP, err := net.ListenTCP(
"tcp",
@@ -732,7 +748,11 @@ func (session *Session) register(ctx context.Context, expires int) (*sipResponse
if err != nil {
return nil, err
}
material, err := authenticateAKA(ctx, session.provider.aka, session.request.Identity, challenge)
preference := ""
if vowifi.IsATT310280(session.request.Identity) {
preference = "isim_strict"
}
material, err := authenticateAKA(ctx, session.provider.aka, session.request.Identity, challenge, preference)
if err != nil {
return nil, err
}
@@ -792,6 +812,10 @@ func (session *Session) buildRegister(
authorizationHeader string,
authorization string,
) ([]byte, error) {
att310280 := vowifi.IsATT310280(session.request.Identity)
if att310280 {
expires = 18400
}
branch, err := randomHex(12)
if err != nil {
return nil, err
@@ -810,6 +834,17 @@ func (session *Session) buildRegister(
session.instanceID,
"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel",
)
if att310280 {
contact = fmt.Sprintf(
`<sip:%s@%s;transport=%s>;+g.3gpp.accesstype="wlan1";audio;+g.3gpp.smsip;`+
`+g.3gpp.icsi-ref="%s";+sip.instance="<%s>"`,
session.identity.user,
contactAddress,
session.transport,
"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel",
session.instanceID,
)
}
o2Germany := usesO2GermanyIMSProfile(session.request.Identity)
supported := "path, gruu"
allow := "REGISTER, INVITE, ACK, CANCEL, BYE, OPTIONS"
@@ -820,6 +855,13 @@ func (session *Session) buildRegister(
supported = "path, gruu, outbound, sec-agree, 100rel, timer"
allow = "INVITE, ACK, CANCEL, BYE, PRACK, UPDATE, INFO, MESSAGE, OPTIONS"
}
if att310280 {
supported = "path,sec-agree,gruu"
}
userAgent := strings.TrimSpace(session.provider.config.UserAgent)
if att310280 && (userAgent == "" || userAgent == "vocat/1") {
userAgent = "SimAdmin VoWiFi"
}
lines := []string{
"REGISTER " + requestURI + " SIP/2.0",
fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", transportUpper, local, branch),
@@ -833,14 +875,23 @@ func (session *Session) buildRegister(
fmt.Sprintf("Expires: %d", expires),
"Supported: " + supported,
"Allow: " + allow,
"User-Agent: " + session.provider.config.UserAgent,
"User-Agent: " + userAgent,
}
if o2Germany {
lines = append(lines, "P-Preferred-Identity: <"+session.identity.public+">")
} else if att310280 {
lines = append(lines,
"P-Preferred-Identity: <"+session.identity.public+">",
`P-Visited-Network-ID: "one.att.net"`,
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;network-provided",
"Cellular-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=3102800000000;cell-info-age=0",
"Accept-Contact: *;+g.3gpp.smsip",
`Accept-Contact: *;+g.3gpp.icsi-ref="urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel"`,
)
}
if session.securityOffered() {
lines = append(lines,
"Security-Client: "+session.securityProposal.headerValue(),
"Security-Client: "+session.securityClientValue(),
"Require: sec-agree",
"Proxy-Require: sec-agree",
)
+69
View File
@@ -442,6 +442,75 @@ func TestO2GermanyInitialRegisterMatchesSupportedIMSProfile(t *testing.T) {
}
}
func TestATT310280DeriveIdentitiesUsesISIMDomains(t *testing.T) {
identities, err := deriveIdentities(vowifi.SIMIdentity{
IMSI: "310280229187733", HomeMCC: "310", HomeMNC: "280",
}, Config{})
if err != nil {
t.Fatalf("deriveIdentities() error = %v", err)
}
if identities.domain != "one.att.net" ||
identities.private != "[email protected]" ||
identities.public != "sip:[email protected]" {
t.Fatalf("AT&T identities = %#v", identities)
}
}
func TestATT310280InitialRegisterMatchesProvisionedProfile(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
identity := vowifi.SIMIdentity{
IMSI: "310280229187733", HomeMCC: "310", HomeMNC: "280",
}
identities, err := deriveIdentities(identity, Config{})
if err != nil {
t.Fatal(err)
}
session := &Session{
provider: &Provider{config: Config{SecurityMode: SecurityRequired, UserAgent: "vocat/1"}},
request: vowifi.IMSRequest{Identity: identity},
identity: identities,
endpoint: pcscfEndpoint{host: "pcscf.example", port: 5060},
transport: "tcp",
conn: client,
callID: "att-test",
fromTag: "tag",
instanceID: "urn:uuid:test",
securityProposal: securityProposal{
spiClient: 1546543, spiServer: 1546542,
portClient: 32773, portServer: 6000,
integrityAlgorithms: []string{"hmac-sha-1-96"},
encryptionAlgorithmsList: []string{"aes-cbc"},
},
}
packet, err := session.buildRegister(1, 3600, "", "")
if err != nil {
t.Fatalf("buildRegister() error = %v", err)
}
request := string(packet)
for _, want := range []string{
"REGISTER sip:one.att.net SIP/2.0",
"Expires: 18400",
"Supported: path,sec-agree,gruu",
"User-Agent: SimAdmin VoWiFi",
`+g.3gpp.accesstype="wlan1";audio;+g.3gpp.smsip`,
"P-Preferred-Identity: <sip:[email protected]>",
`P-Visited-Network-ID: "one.att.net"`,
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;network-provided",
"Cellular-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=3102800000000;cell-info-age=0",
"Accept-Contact: *;+g.3gpp.smsip",
"Security-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; prot=esp; mod=trans; spi-c=1546543; spi-s=1546542; port-c=32773; port-s=6000",
`username="[email protected]"`,
`uri="sip:one.att.net"`,
} {
if !strings.Contains(request, want) {
t.Fatalf("AT&T REGISTER omits %q:\n%s", want, request)
}
}
}
func serveRefreshFailure(listener *net.UDPConn, nonce string) error {
var callID string
for step := 0; step < 3; step++ {
+15
View File
@@ -12,6 +12,8 @@ import (
"sort"
"strconv"
"strings"
"vocat/internal/vowifi"
)
type SecurityMode string
@@ -143,6 +145,19 @@ func (proposal securityProposal) headerValue() string {
return strings.Join(values, ", ")
}
func (session *Session) securityClientValue() string {
if vowifi.IsATT310280(session.request.Identity) {
return fmt.Sprintf(
"ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; prot=esp; mod=trans; spi-c=%d; spi-s=%d; port-c=%d; port-s=%d",
session.securityProposal.spiClient,
session.securityProposal.spiServer,
session.securityProposal.portClient,
session.securityProposal.portServer,
)
}
return session.securityProposal.headerValue()
}
func (proposal securityProposal) encryptionAlgorithm() string {
if strings.EqualFold(strings.TrimSpace(proposal.encryption), "null") {
return "null"
+4 -9
View File
@@ -645,18 +645,13 @@ func DeriveEPDG(identity SIMIdentity) (string, error) {
}
return strings.ToLower(configured), nil
}
if IsATT310280(identity) {
return att310280EPDG, nil
}
if err := identity.validate(); err != nil {
return "", err
}
mnc := strings.TrimSpace(identity.HomeMNC)
for len(mnc) < 3 {
mnc = "0" + mnc
}
return fmt.Sprintf(
"epdg.epc.mnc%s.mcc%s.pub.3gppnetwork.org",
mnc,
strings.TrimSpace(identity.HomeMCC),
), nil
return standardEPDGHostname(identity.HomeMCC, identity.HomeMNC), nil
}
func normalizeProxyRoute(route ProxyRoute) (ProxyRoute, error) {
+3 -3
View File
@@ -53,18 +53,18 @@ func (adapter *PCSCAdapter) ReadIdentity(ctx context.Context, deviceID string) (
mncLength := identity.MNCLength
if mncLength != 2 && mncLength != 3 {
if mcc, mnc, ok := assignedHomePLMN(identity.IMSI); ok {
return SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC}, nil
return applyAssignedCarrierRoute(SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC}), nil
}
return SIMIdentity{}, ErrEC20MNCUnavailable
}
if len(identity.IMSI) < 3+mncLength {
return SIMIdentity{}, errors.New("vocat: USB SIM IMSI is shorter than its EF_AD home PLMN")
}
return SIMIdentity{
return applyAssignedCarrierRoute(SIMIdentity{
ICCID: identity.ICCID, IMSI: identity.IMSI,
HomeMCC: identity.IMSI[:3], HomeMNC: identity.IMSI[3 : 3+mncLength],
SMSC: identity.SMSC,
}, nil
}), nil
}
func (adapter *PCSCAdapter) ReadSMSCenter(ctx context.Context, deviceID string) (string, error) {
+10
View File
@@ -125,6 +125,16 @@ func TestDeriveEPDGUsesExplicitPLMNAndNeverIMSIHeuristics(t *testing.T) {
},
want: "epdg.epc.mnc260.mcc310.pub.3gppnetwork.org",
},
{
name: "AT&T 310280 uses carrier endpoint",
identity: SIMIdentity{
ICCID: "89012804332291663965",
IMSI: "310280229187733",
HomeMCC: "310",
HomeMNC: "280",
},
want: "epdg.epc.att.net",
},
{
name: "explicit endpoint",
identity: SIMIdentity{
+8
View File
@@ -317,6 +317,14 @@ type AKAProvider interface {
Authenticate(context.Context, SIMIdentity, AKAChallenge) (AKAResult, error)
}
// PreferredAKAProvider optionally lets an AKA provider select a carrier-
// provisioned application such as ISIM. Providers that only expose USIM keep
// implementing AKAProvider unchanged.
type PreferredAKAProvider interface {
AKAProvider
AuthenticateWithPreference(context.Context, SIMIdentity, AKAChallenge, string) (AKAResult, error)
}
// RadioController owns the host/modem radio projection. EnterVoWiFiRFOff must
// not toggle the independent pure-airplane policy; Restore must return to the
// captured pre-transaction state.
+10 -4
View File
@@ -345,11 +345,14 @@ FIRST_INSTALL=0
INITIAL_ADMIN_PASSWORD=""
bootstrap_admin() {
local candidate="${1:-$BINARY_PATH}"
local secret result
secret=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')
[ -n "$secret" ] || die "Failed to generate a random secret." "Failed to generate a random secret."
result=$(printf '%s\n' "$secret" | "$BINARY_PATH" bootstrap-admin --database /opt/vocat/data/vocat.db --username admin) || \
die "Failed to initialize the administrator." "Failed to initialize the administrator."
result=$(printf '%s\n' "$secret" | "$candidate" bootstrap-admin --database /opt/vocat/data/vocat.db --username admin) || \
die \
"待安装版本无法读取或升级现有数据库;当前程序尚未被替换,请检查数据库与版本兼容性。" \
"The candidate version cannot read or migrate the existing database; the installed program was not replaced. Check database and version compatibility."
if [ "$result" = "created" ]; then
FIRST_INSTALL=1
INITIAL_ADMIN_PASSWORD="$secret"
@@ -532,9 +535,12 @@ fi
resolve_target_version
skip_if_equal
download_and_verify
install_binary
ensure_data_dir
bootstrap_admin
# Validate the database with the downloaded binary before replacing the
# installed program. In particular, a release with an older schema must never
# overwrite a newer working binary and leave the service in a restart loop.
bootstrap_admin "${VOCAT_TMP}/vocat"
install_binary
setup_env
write_service
enable_and_start