Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions cmd/vocat/install_script_test.go
Original file line number Diff line number Diff line change
@@ -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 changes: 118 additions & 0 deletions internal/store/domain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
117 changes: 117 additions & 0 deletions internal/store/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)`,
}
Comment on lines +364 to +368
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
}
Expand Down
5 changes: 3 additions & 2 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
_ "modernc.org/sqlite"
)

const schemaVersion = 16
const schemaVersion = 19

var ErrNotFound = errors.New("store: not found")

Expand Down Expand Up @@ -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
}
Expand Down
14 changes: 10 additions & 4 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Comment on lines +539 to +543
setup_env
write_service
enable_and_start
Expand Down
Loading