12
Backup, Sync Relay, dan API Server
Backup dan sinkronisasi Termimus berbagi satu bahan baku: snapshot basis data lokal yang dibungkus sebelum meninggalkan mesin. Backup menulis bungkusan itu ke berkas, sedangkan sync mendorongnya ke relay Go yang hanya menyimpan ciphertext. Relay tidak pernah menerima passphrase; kunci tetap diturunkan di klien, dan mekanismenya dibahas di Vault dan Model Kriptografi. Tabel yang ikut diekspor dijelaskan di Skema Database Lokal.
Dua format berkas backup
BackupBundle adalah snapshot penuh basis data: metadata salt dan verifier vault, lalu folders, credentials, hosts, port_forwards, snippets, known_hosts, tombstones, dan workspace_presets src-tauri/src/db/models.rs:286-3081. Pembangunnya, export_backup_bundle, selalu menulis format_version: 1 dan mengisi setiap daftar lewat metode list_* src-tauri/src/db/mod.rs:945-9662.
Pembungkusan terjadi satu lapis di atasnya. Command backup_export mengembalikan JSON BackupBundle apa adanya bila passphrase kosong, tetapi bila passphrase ada ia memanggil VaultManager::encrypt_with_passphrase lalu menyusun EncryptedBackupEnvelope berisi format_version: 2, encrypted: true, dan kdf: "argon2id" src-tauri/src/commands/mod.rs:1448-14763. Envelope itu menyimpan salt, nonce, dan ciphertext sebagai tiga field terpisah src-tauri/src/db/models.rs:324-3374.
format_version | Wrapper | Kapan dipakai | Yang terbaca relay |
|---|---|---|---|
| 1 | BackupBundle | backup_export tanpa passphrase | seluruh isi, termasuk alamat host dan username |
| 2 | EncryptedBackupEnvelope | passphrase tidak kosong, termasuk setiap sync_push | hanya salt, nonce, ciphertext |
Saat dibaca kembali, backup_import memakai field encrypted sebagai penentu jalur: bila true ia mem-parse envelope, menolak versi selain 2 dengan pesan "Unsupported encrypted backup version: {}", lalu memanggil VaultManager::decrypt_with_passphrase src-tauri/src/commands/mod.rs:1489-15205. Bila encrypted tidak ada atau false, isi dianggap bundle format 1 dan divalidasi format_version == 1 src-tauri/src/commands/mod.rs:1512-15206.
Setelah impor, command mengunci vault ketika metadata vault ikut dipulihkan bersama replace_all, lalu menulis safety snapshot ke auto-backup-pre-restore.json di direktori konfigurasi dan mengisi summary.safety_snapshot_path src-tauri/src/commands/mod.rs:1522-15467. Snapshot itu hanya dibuat bila replace_all true, karena hanya jalur itu yang menghapus tabel lebih dulu src-tauri/src/db/mod.rs:968-9968.
⚠️
backup_importdenganreplace_all: truemenjalankanDELETEpadatombstones,known_hosts,snippets,workspace_presets,port_forwards,hosts,credentials, danfolderssebelum menulis apa pun src-tauri/src/db/mod.rs:968-9968. Jalur sync tidak memakaireplace_all, sehingga auto-pull selalu merge.
Dari basis data lokal ke relay
graph LR
DB[("termimus.db")] -->|"export_backup_bundle"| B1["BackupBundle<br/>format_version 1"]
B1 -->|"passphrase kosong"| FILE["berkas .json plain"]
B1 -->|"encrypt_with_passphrase"| B2["EncryptedBackupEnvelope<br/>format_version 2"]
B2 -->|"sync_push"| RELAY["POST /api/v1/sync/bundle"]
RELAY -->|"simpan ciphertext"| SQ[("sync_revisions")]
RELAY -->|hub.Broadcast| HUB{{"SYNC_UPDATED"}}
HUB --> PEERS["perangkat lain"]
PEERS -->|"sync_pull"| DB
style DB fill:#336791,color:#fff
style SQ fill:#336791,color:#fff
style B2 fill:#2d7d46,color:#fff
style RELAY fill:#2d7d46,color:#fff
style HUB fill:#2d7d46,color:#fff
style B1 fill:#8b5cf6,color:#fff
style FILE fill:#f59e0b,color:#000
style PEERS fill:#f59e0b,color:#000
%% fig: Jalur satu snapshot dari basis data lokal ke perangkat lain
%% desc: Basis data diekspor menjadi BackupBundle format 1, dibungkus menjadi EncryptedBackupEnvelope format 2, lalu didorong ke relay yang membalas dengan event SYNC_UPDATED.sync_push menolak passphrase kosong dengan pesan "Sync Passphrase is required. Termimus enforces zero-knowledge E2EE encryption before uploading to the relay.", memanggil backup_export dengan passphrase tersebut, lalu mengirim PushBundleRequest berisi device_id, device_name, dan encrypted_blob ke POST /api/v1/sync/bundle src-tauri/src/commands/mod.rs:1716-17339.
Seluruh I/O sync berjalan di Rust lewat reqwest dengan timeout 30 detik dan header Bearer dari apply_auth_header, agar WebKitGTK tidak memblokir POST ke http:// sebagai active mixed content src-tauri/src/commands/mod.rs:1551-162010. Respons non-2xx dipetakan ke pesan server; kode 401 menjadi "Authentication failed: invalid or missing Auth Token (configured via TERMIMUS_AUTH_TOKEN on server)" src-tauri/src/commands/mod.rs:1740-175411.
Dari sisi frontend, push() membaca serverUrl, authToken, syncPassword, dan deviceName, menyalakan syncStatus: "syncing", lalu menyimpan nomor versi dari perintah Rust ke latestServerVersion src/stores/useSyncStore.ts:126-16312. Perubahan host dan snippet memanggil triggerAutoPush(), yang menunda 2000 ms dan melewati push bila syncStatus === "syncing" src/stores/useSyncStore.ts:210-22913.
sequenceDiagram
participant UI as useSyncStore
participant RS as sync_push
participant V as VaultManager
participant H as handlers.PostBundle
participant D as db.SaveRevision
participant HB as hub.Hub
participant P2 as perangkat lain
UI->>RS: push() setelah triggerAutoPush 2000 ms
RS->>RS: passphrase kosong ditolak
RS->>V: backup_export(state, Some(password))
V-->>RS: EncryptedBackupEnvelope format_version 2
RS->>H: POST /api/v1/sync/bundle dengan Bearer token
H->>H: MaxBytesReader 25MB, cek format_version harus 2
H->>D: SaveRevision(device_id, device_name, blob)
D-->>H: newVersion
H->>HB: Broadcast(SYNC_UPDATED)
H-->>RS: 200 OK dengan version baru
HB->>P2: SYNC_UPDATED dari device_id lain
P2->>P2: pull() lalu backup_import dengan replace_all false
%% fig: Siklus satu request POST /api/v1/sync/bundle
%% desc: Klien Rust membungkus basis data, server memvalidasi kebijakan E2EE, menyimpan revisi, lalu menyiarkan SYNC_UPDATED ke perangkat lain yang langsung melakukan pull.Kebijakan relay: hanya ciphertext
PostBundle menolak setiap payload yang tidak berbentuk envelope terenkripsi format 2.
| Kondisi | Status | error yang dikembalikan |
|---|---|---|
| body melebihi 25 MB atau gagal dibaca | 400 | Failed to read request body or payload exceeds 25MB limit |
| JSON tidak dapat di-parse | 400 | Invalid JSON payload: ... |
encrypted_blob kosong atau null | 400 | encrypted_blob is required |
| blob bukan JSON valid | 400 | encrypted_blob must be valid JSON |
encrypted false atau format_version bukan 2 | 400 | Server policy: only version 2 encrypted bundles (E2EE) are accepted to prevent plaintext leakage |
Batas 25 MB dipasang lewat http.MaxBytesReader agar permintaan besar tidak menghabiskan memori server/internal/handlers/handlers.go:90-9714. Field encrypted_blob wajib terisi, dan nilainya boleh berupa string JSON ter-escape maupun objek JSON mentah server/internal/handlers/handlers.go:100-11015. Pemeriksaan terakhir mem-parse encrypted dan format_version dari dalam blob itu sendiri, bukan dari field terpisah; kombinasi yang salah ditolak dengan pesan kebijakan di atas server/internal/handlers/handlers.go:124-13716.
Bila lolos, device_id dan device_name kosong diisi unknown-device dan Termimus Client, SaveRevision menyimpan revisi, lalu hub.Broadcast memancarkan event SYNC_UPDATED lewat WebSocket server/internal/handlers/handlers.go:139-16017.
Skema dan retensi relay
erDiagram
sync_revisions {
integer id PK
integer version UK
text device_id
text device_name
text encrypted_blob
datetime created_at
}
devices {
text id PK
text name
datetime last_sync_at
datetime created_at
}
devices ||--o{ sync_revisions : "device_id"
%% fig: Dua tabel basis data relay
%% desc: sync_revisions menyimpan blob terenkripsi per versi, sedangkan devices mencatat perangkat yang pernah mendorong revisi.Basis data relay hanya SQLite dengan dua tabel: sync_revisions (id, version UNIQUE, device_id, device_name, encrypted_blob, created_at) dan devices (id, name, last_sync_at, created_at), dijalankan dengan PRAGMA journal_mode = WAL dan busy_timeout = 5000 server/internal/db/db.go:60-8818.
SaveRevision berjalan dalam satu transaksi: ia membaca MAX(version), memakai nextVersion = current + 1 atau 1 untuk basis data kosong, menyisipkan baris baru, lalu menghapus version <= nextVersion - 15 karena maxRevisionsToKeep bernilai 15; di transaksi yang sama ia melakukan upsert ke devices server/internal/db/db.go:135-19019.
⚠️ Relay menyimpan maksimal 15 revisi terakhir. Versi yang lebih lama dihapus, jadi relay bukan arsip riwayat; arsipkan hasil
backup_exportsendiri bila butuh titik pulih jangka panjang.
Status dibaca lewat dua handler kecil. SyncStatus membalas has_data: false dan latest_version: 0 ketika GetLatestRevision tidak menemukan baris server/internal/handlers/handlers.go:39-6220. GetBundle membalas 404 "No sync bundle available on server yet" ketika blob belum ada server/internal/handlers/handlers.go:64-8121.
WebSocket SYNC_UPDATED dan auto-pull
useLiveSync mengubah skema http menjadi ws, menambahkan query ?token=<token> bila authToken ada, lalu membuka /api/v1/sync/ws src/hooks/useLiveSync.ts:22-3022. Pada setiap pesan, ia mengabaikan event yang device_id-nya sama dengan perangkat ini dan memanggil pull() untuk event dari perangkat lain src/hooks/useLiveSync.ts:46-6323. Saat koneksi tertutup, ia menjadwalkan connect ulang setiap 5000 ms src/hooks/useLiveSync.ts:65-6924.
Di sisi server, Hub memakai writeWait 10 detik, pongWait 60 detik, pingPeriod 54 detik, dan maxMessageSize 512 byte; pesan yang disiarkan bertipe EventMessage dengan field type, version, device_id, dan device_name server/internal/hub/hub.go:12-2425. Live Sync baru bisa dinyalakan setelah passphrase diisi: setAutoSync menolak dengan pesan "Please set a Sync Passphrase before enabling Live Sync to ensure end-to-end encryption." src/stores/useSyncStore.ts:87-9326.
Menggabungkan hasil pull
sync_pull mengambil GET /api/v1/sync/bundle, mengembalikan "No sync data found on server" bila encrypted_blob kosong, lalu memanggil backup_import dengan replace_all = false src-tauri/src/commands/mod.rs:1766-179927. Artinya pull selalu merge, bukan penggantian.
graph TD
IN["host masuk dari bundle"] --> T{"is_tombstoned(host, id)"}
T -->|"tombstone lebih baru"| SKIP["entri dilewati"]
T -->|tidak ada tombstone| L{"get_host(id)"}
L -->|"local.updated_at lebih baru"| SKIP
L -->|tidak ada atau lebih lama| SAVE["save_host"]
SAVE --> SUM["masuk ImportSummary.hosts"]
style IN fill:#8b5cf6,color:#fff
style T fill:#f59e0b,color:#000
style L fill:#f59e0b,color:#000
style SKIP fill:#dc2626,color:#fff
style SAVE fill:#2d7d46,color:#fff
style SUM fill:#2d7d46,color:#fff
%% fig: Keputusan merge satu entri saat pull
%% desc: Setiap entri diperiksa terhadap tombstone lokal dan timestamp lokal sebelum `save_host` dipanggil, sehingga data yang lebih baru tidak tertimpa.Merge membandingkan updated_at (atau created_at bila kosong) per entitas: baris lokal yang lebih baru dilewati, tombstone yang lebih baru menghapus entitas lokal, dan tombstone lokal membatalkan entri masuk yang lebih lama src-tauri/src/db/mod.rs:1080-113428. Setelah semua baris masuk, rujukan yang menggantung dibersihkan: folder_id, jump_host_id, dan parent_id di-set NULL bila induknya tidak ada src-tauri/src/db/mod.rs:1185-120029.
known_hosts diperlakukan khusus: saat merge, entri dengan fingerprint berbeda tidak ditimpa, hanya dihitung sebagai known_hosts_conflicts src-tauri/src/db/mod.rs:1202-123330. Perilaku verifikasi ini dibahas di Verifikasi Host Key TOFU. Hasil akhirnya diringkas dalam ImportSummary: jumlah per tabel, known_hosts_conflicts, workspace_presets, vault_meta_restored, dan safety_snapshot_path src-tauri/src/db/models.rs:310-32231.
Permukaan API relay
| Method | Path | Handler | Autentikasi |
|---|---|---|---|
GET | /health | h.Health | publik |
GET | /api/v1/sync/status | h.SyncStatus | Bearer |
GET | /api/v1/sync/bundle | h.GetBundle | Bearer |
POST | /api/v1/sync/bundle | h.PostBundle | Bearer |
GET | /api/v1/sync/devices | h.GetDevices | Bearer |
GET | /api/v1/sync/ws | h.WebSocket | token query |
/api/v1/sync/bundle memakai satu HandleFunc untuk dua method dan membalas "Method not allowed" untuk selain GET dan POST server/cmd/server/main.go:48-6732. AuthMiddleware.Wrap meloloskan semua request bila requiredToken kosong, menerima Authorization: Bearer <token> atau ?token=, membandingkannya dengan subtle.ConstantTimeCompare, lalu membalas 401 "Unauthorized: invalid or missing authentication token" server/internal/middleware/auth.go:18-4933. README relay menyebut aturan yang sama untuk seluruh endpoint /api/v1/sync/* server/README.md:6134.
Menjalankan relay
| Variable | Default | Fungsi |
|---|---|---|
PORT | 8080 | port HTTP dan WebSocket |
DATA_DIR | ./data | direktori basis data |
DB_PATH | <DATA_DIR>/sync.db | path berkas SQLite |
TERMIMUS_AUTH_TOKEN | "" | token Bearer; kosong berarti open mode |
Nilai default itu dibaca config.Load dari environment server/internal/config/config.go:15-2735 dan didokumentasikan juga di server/README.md:50-5536.
⚠️ Saat
TERMIMUS_AUTH_TOKENkosong,main.gomencetak[Security Warning] TERMIMUS_AUTH_TOKEN is not set! Running in OPEN mode.dan seluruh endpoint sync dapat diakses tanpa autentikasi server/cmd/server/main.go:42-4637.
WebSocket melewati reverse proxy hanya bila header upgrade diteruskan; contoh Nginx memasang proxy_set_header Upgrade $http_upgrade dan proxy_set_header Connection "upgrade" server/README.md:128-14238.