From 0d1779bf25e6cb65e939eaf7512a268f546ffb22 Mon Sep 17 00:00:00 2001 From: Meng Meng <227010654+MengMengCode@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:59:45 +0800 Subject: [PATCH] fix: prevent schema-incompatible installer downgrades (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add schema 17–19 compatibility migrations --- cmd/vocat/install_script_test.go | 31 ++++++++ internal/store/domain_test.go | 118 +++++++++++++++++++++++++++++++ internal/store/migrations.go | 117 ++++++++++++++++++++++++++++++ internal/store/store.go | 5 +- scripts/install.sh | 14 ++-- 5 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 cmd/vocat/install_script_test.go diff --git a/cmd/vocat/install_script_test.go b/cmd/vocat/install_script_test.go new file mode 100644 index 0000000..14668cc --- /dev/null +++ b/cmd/vocat/install_script_test.go @@ -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") + } +} diff --git a/internal/store/domain_test.go b/internal/store/domain_test.go index e7129a8..14cd68e 100644 --- a/internal/store/domain_test.go +++ b/internal/store/domain_test.go @@ -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") diff --git a/internal/store/migrations.go b/internal/store/migrations.go index cd0ca99..f3c7222 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -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 } diff --git a/internal/store/store.go b/internal/store/store.go index c165bda..e68f87c 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 } diff --git a/scripts/install.sh b/scripts/install.sh index 1a2eef6..f0a0913 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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