Lompat ke konten
dokumentasi.id
Commit 0da916f
v1
Star0

Kamu membaca versi lama. Halaman ini hanya bisa dibaca.

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-308. Pembangunnya, export_backup_bundle, selalu menulis format_version: 1 dan mengisi setiap daftar lewat metode list_* src-tauri/src/db/mod.rs:945-966.

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-1476. Envelope itu menyimpan salt, nonce, dan ciphertext sebagai tiga field terpisah src-tauri/src/db/models.rs:324-337.

format_versionWrapperKapan dipakaiYang terbaca relay
1BackupBundlebackup_export tanpa passphraseseluruh isi, termasuk alamat host dan username
2EncryptedBackupEnvelopepassphrase tidak kosong, termasuk setiap sync_pushhanya 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-1520. Bila encrypted tidak ada atau false, isi dianggap bundle format 1 dan divalidasi format_version == 1 src-tauri/src/commands/mod.rs:1512-1520.

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-1546. Snapshot itu hanya dibuat bila replace_all true, karena hanya jalur itu yang menghapus tabel lebih dulu src-tauri/src/db/mod.rs:968-996.

⚠️ backup_import dengan replace_all: true menjalankan DELETE pada tombstones, known_hosts, snippets, workspace_presets, port_forwards, hosts, credentials, dan folders sebelum menulis apa pun src-tauri/src/db/mod.rs:968-996. Jalur sync tidak memakai replace_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.
Fig. 1: Jalur satu snapshot dari basis data lokal ke perangkat lainBasis 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-1733.

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-1620. 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-1754.

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-163. Perubahan host dan snippet memanggil triggerAutoPush(), yang menunda 2000 ms dan melewati push bila syncStatus === "syncing" src/stores/useSyncStore.ts:210-229.

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.
Fig. 2: Siklus satu request POST /api/v1/sync/bundleKlien 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.

KondisiStatuserror yang dikembalikan
body melebihi 25 MB atau gagal dibaca400Failed to read request body or payload exceeds 25MB limit
JSON tidak dapat di-parse400Invalid JSON payload: ...
encrypted_blob kosong atau null400encrypted_blob is required
blob bukan JSON valid400encrypted_blob must be valid JSON
encrypted false atau format_version bukan 2400Server 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-97. Field encrypted_blob wajib terisi, dan nilainya boleh berupa string JSON ter-escape maupun objek JSON mentah server/internal/handlers/handlers.go:100-110. 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-137.

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-160.

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.
Fig. 3: Dua tabel basis data relaysync_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-88.

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-190.

⚠️ Relay menyimpan maksimal 15 revisi terakhir. Versi yang lebih lama dihapus, jadi relay bukan arsip riwayat; arsipkan hasil backup_export sendiri 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-62. GetBundle membalas 404 "No sync bundle available on server yet" ketika blob belum ada server/internal/handlers/handlers.go:64-81.

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-30. 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-63. Saat koneksi tertutup, ia menjadwalkan connect ulang setiap 5000 ms src/hooks/useLiveSync.ts:65-69.

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-24. 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-93.

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-1799. 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.
Fig. 4: Keputusan merge satu entri saat pullSetiap 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-1134. 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-1200.

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-1233. 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-322.

Permukaan API relay

MethodPathHandlerAutentikasi
GET/healthh.Healthpublik
GET/api/v1/sync/statush.SyncStatusBearer
GET/api/v1/sync/bundleh.GetBundleBearer
POST/api/v1/sync/bundleh.PostBundleBearer
GET/api/v1/sync/devicesh.GetDevicesBearer
GET/api/v1/sync/wsh.WebSockettoken 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-67. 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-49. README relay menyebut aturan yang sama untuk seluruh endpoint /api/v1/sync/* server/README.md:61.

Menjalankan relay

VariableDefaultFungsi
PORT8080port HTTP dan WebSocket
DATA_DIR./datadirektori basis data
DB_PATH<DATA_DIR>/sync.dbpath 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-27 dan didokumentasikan juga di server/README.md:50-55.

⚠️ Saat TERMIMUS_AUTH_TOKEN kosong, main.go mencetak [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-46.

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-142.