11
Port Forwarding dan Tunnel
Port forwarding di Termimus dijalankan oleh TunnelManager di src-tauri/src/tunnel/mod.rs:14-211. Manager menyimpan tunnel yang sedang hidup di dalam HashMap<String, ActiveTunnel> yang dibungkus Mutex, dan setiap entri membawa rule_id serta stop_tx bertipe oneshot::Sender<()>. Karena kuncinya adalah rule_id, satu rule hanya boleh punya satu listener aktif. Dua method baca, is_active dan list_active, hanya membaca kunci HashMap itu tanpa menyentuh jaringan src-tauri/src/tunnel/mod.rs:30-362.
Siklus hidup satu tunnel
start_local_forward adalah satu-satunya pintu masuk untuk menyalakan tunnel. Urutannya: cek duplikat, buka koneksi SSH, autentikasi, bind listener lokal, spawn task penerima koneksi, lalu daftarkan rule ke HashMap.
Pemeriksaan duplikat dilakukan sebelum menyentuh jaringan. Jika rule_id sudah ada di active, fungsi langsung mengembalikan "Tunnel is already running" src-tauri/src/tunnel/mod.rs:55-603.
Setelah itu fungsi membangun SshClientHandler { address, port, db }, membuka TcpStream::connect ke host SSH, memanggil configure_tcp_stream, lalu menyerahkan socket itu ke client::connect_stream. Dua kegagalan di tahap ini diberi pesan berbeda: "Tunnel TCP connection failed: {e}" dan "Tunnel SSH connection failed: {e}" src-tauri/src/tunnel/mod.rs:62-774. Handler yang dipakai adalah handler yang sama dengan sesi terminal, sehingga verifikasi host key tetap berlaku; mekanismenya dijelaskan di Verifikasi Host Key TOFU.
Autentikasi bercabang pada SshAuth. Varian Password memakai authenticate_password; varian PrivateKey mendekode pem lewat decode_secret_key lalu memanggil authenticate_publickey_smart. Bila server menolak, hasilnya "Authentication rejected by server" src-tauri/src/tunnel/mod.rs:79-935.
Listener lokal baru dibuat setelah autentikasi berhasil. Alamat bind dibentuk dari format!("{local_address}:{local_port}") dan kegagalan bind dilaporkan sebagai "Failed to bind {bind_addr}: {e}" src-tauri/src/tunnel/mod.rs:95-996. Ini penting: tunnel yang gagal autentikasi tidak pernah meninggalkan port lokal yang terbuka.
Meneruskan satu koneksi
Task hasil tokio::spawn menjalankan tokio::select! antara stop_rx dan listener.accept(). Setiap koneksi yang diterima di-spawn lagi ke forward_connection dengan originator_ip dan originator_port dari peer_addr, sehingga satu koneksi lambat tidak memblokir koneksi lain. Task mengirim event tunnel-status-{rule_id} bernilai "started" di awal dan "stopped" saat loop berhenti; kegagalan per koneksi dicatat sebagai "Tunnel forwarding error: {e}" dan kegagalan accept sebagai "Tunnel accept error: {e}" src-tauri/src/tunnel/mod.rs:106-1427. Setelah task di-spawn, barulah rule dimasukkan ke active src-tauri/src/tunnel/mod.rs:144-1478.
Diagram berikut menunjukkan perjalanan satu paket dari listener lokal sampai ke remote_address:remote_port.
sequenceDiagram
autonumber
participant App as Local client
participant L as TcpListener local_address:local_port
participant F as forward_connection
participant C as direct-tcpip channel
participant R as remote_address:remote_port
App->>L: TCP connect
L->>F: accept() -> (stream, peer_addr)
F->>C: channel_open_direct_tcpip(remote_address, remote_port, originator_ip, originator_port)
App->>C: stream.read(buf) -> channel.data(buf)
R-->>C: ChannelMsg::Data
C-->>App: stream.write_all(data)
App->>C: read 0 byte -> channel.eof()
C-->>F: ChannelMsg::Eof / Close / None%% fig: Perjalanan paket dari listener lokal ke remote target %% desc: Setiap koneksi TCP lokal menjadi satu channel direct-tcpip, lalu data dipompa dua arah sampai salah satu sisi menutup channel.
forward_connection mengaktifkan set_nodelay(true) pada socket lokal, lalu membuka channel lewat channel_open_direct_tcpip(remote_address, remote_port as u32, originator_ip, originator_port); kegagalannya berbunyi "Failed to open direct-tcpip channel: {e}" src-tauri/src/tunnel/mod.rs:159-1719. Perhatikan bahwa remote_address dikirim apa adanya ke server SSH, jadi tujuan akhirnya adalah jaringan yang terlihat dari sisi server, bukan dari mesin lokal.
Pemompaan data memakai buffer vec![0u8; 32 * 1024] dan satu tokio::select! dua arah. Arah lokal ke remote: Ok(0) menandai EOF dan memicu channel.eof(), sedangkan Ok(n) mengirim channel.data(&buf[..n]). Arah remote ke lokal: ChannelMsg::Data ditulis lewat stream.write_all, dan ChannelMsg::Eof, ChannelMsg::Close, atau None mengakhiri loop src-tauri/src/tunnel/mod.rs:173-20810.
Mematikan tunnel
stop menghapus entri dari HashMap lalu mengirim sinyal lewat stop_tx.send(()). Pengiriman sinyal itu yang membuat tokio::select! di task penerima keluar dari loop, menutup listener, dan mengirim event "stopped" src-tauri/src/tunnel/mod.rs:150-15611. Karena entri dihapus lebih dulu, stop yang dipanggil dua kali tidak menghasilkan error: ia hanya tidak menemukan apa pun untuk dihentikan.
Diagram berikut memisahkan jalur kendali dari frontend dan tempat status sebenarnya disimpan.
graph LR
Card["TunnelCard toggle"] --> Store["useTunnelStore.toggleTunnel"]
Store --> Invoke["api.startTunnel / api.stopTunnel"]
Invoke --> Cmd["tunnel_start / tunnel_stop"]
Cmd --> Rule["port_forwards row"]
Cmd --> Registry["TunnelManager.active HashMap"]
Registry --> Task["tokio task + TcpListener"]
Task --> Event["event tunnel-status-rule_id"]
Store --> List["api.listActiveTunnels"]
List --> Registry
style Registry fill:#336791,color:#fff
style Rule fill:#336791,color:#fff
style Task fill:#2d7d46,color:#fff
style Event fill:#8b5cf6,color:#fff
style Cmd fill:#f59e0b,color:#000%% fig: Jalur kendali tunnel dari UI ke registry %% desc: Frontend memanggil command Tauri, command membaca rule dari SQLite lalu mengubah isi HashMap TunnelManager, dan status aktif dibaca kembali lewat tunnel_active_list.
Command dan jembatan ke frontend
Lima command Tauri menjadi permukaan API fitur ini. tunnel_rule_list, tunnel_rule_save, dan tunnel_rule_delete hanya menyentuh database; ketiganya tidak pernah menyalakan listener src-tauri/src/commands/mod.rs:1190-122812. tunnel_start adalah satu-satunya yang menyentuh jaringan: ia membaca rule lewat get_port_forward, membaca host lewat get_host, menyelesaikan kredensial lewat resolve_host_auth, lalu memanggil start_local_forward src-tauri/src/commands/mod.rs:1230-126613. Dua kegagalan lookup punya pesan sendiri, "Port forwarding rule not found" dan "Target host not found". tunnel_stop dan tunnel_active_list hanya meneruskan ke stop dan list_active src-tauri/src/commands/mod.rs:1268-128114.
Di sisi TypeScript, keenam pemanggilan dibungkus sebagai listTunnelRules, saveTunnelRule, deleteTunnelRule, startTunnel, stopTunnel, dan listActiveTunnels src/lib/api.ts:328-33515.
| Command Tauri | Membaca/menulis | Menyentuh jaringan |
|---|---|---|
tunnel_rule_list | list_port_forwards | tidak |
tunnel_rule_save | get_port_forward, save_port_forward | tidak |
tunnel_rule_delete | delete_port_forward + tombstone | tidak |
tunnel_start | get_port_forward, get_host | ya, lewat start_local_forward |
tunnel_stop | TunnelManager.active | menutup listener |
tunnel_active_list | TunnelManager.active | tidak |
Store dan tampilan
useTunnelStore menyimpan rules dan activeRuleIds sebagai Set<string>. refresh memanggil api.listTunnelRules() dan api.listActiveTunnels() secara paralel dengan Promise.all, lalu menyusun ulang activeRuleIds dari daftar id yang dikembalikan backend. Jadi status aktif di UI selalu berasal dari TunnelManager, bukan dari state lokal optimistis src/stores/useTunnelStore.ts:31-4416.
toggleTunnel memilih arah berdasarkan keanggotaan id di activeRuleIds, menyetel startingRuleId selama operasi berjalan, dan menulis error berbentuk Tunnel error: ${String(e)} bila gagal. deleteRule mematikan tunnel lebih dulu jika rule sedang aktif, baru menghapus barisnya src/stores/useTunnelStore.ts:51-7717. Di TunnelCard, indikator titik hijau berdenyut dan tombol Stop Tunnel hanya muncul ketika isActive bernilai benar; tombol Edit dan Delete dinonaktifkan selama tunnel aktif src/components/tunnels/TunnelView.tsx:339-35718. Kartu juga menampilkan pasangan local_address:local_port berlabel Local: dan remote_address:remote_port berlabel Target:, plus host perantara sebagai via <label> src/components/tunnels/TunnelView.tsx:382-40419.
Tabel berikut merangkum field yang harus diisi saat membuat rule.
| Field | Nilai awal di TunnelModal | Wajib |
|---|---|---|
label | kosong | ya, pesan "Label is required" |
host_id | host pertama | ya, pesan "Please select an SSH host" |
forward_type | "local" | tidak divalidasi |
local_address | "127.0.0.1" | tidak, jatuh ke "127.0.0.1" |
local_port | 8080 | ya |
remote_address | "127.0.0.1" | tidak, jatuh ke "127.0.0.1" |
remote_port | 80 | ya |
Saat submit, TunnelModal merakit PortForwardInput dan memanggil saveRule(input, editingRule?.id); local_address dan remote_address yang kosong diganti "127.0.0.1" sebelum dikirim src/components/tunnels/TunnelModal.tsx:45-6620. Nilai awalnya sendiri ditetapkan di state komponen src/components/tunnels/TunnelModal.tsx:12-2021.
Penyimpanan rule
Rule disimpan di tabel port_forwards dengan id sebagai primary key dan foreign key host_id ke hosts(id) yang memakai ON DELETE CASCADE; menghapus host otomatis menghapus rule-nya src-tauri/src/db/mod.rs:79-9022. Skema lengkap dan migration-nya ada di Skema Database Lokal.
| Kolom | Tipe | Default |
|---|---|---|
id | TEXT | primary key |
host_id | TEXT | tidak ada, NOT NULL |
label | TEXT | tidak ada, NOT NULL |
forward_type | TEXT | 'local' |
local_address | TEXT | '127.0.0.1' |
local_port | INTEGER | tidak ada, NOT NULL |
remote_address | TEXT | '127.0.0.1' |
remote_port | INTEGER | tidak ada, NOT NULL |
created_at | TEXT | tidak ada, NOT NULL |
save_port_forward memakai INSERT ... ON CONFLICT(id) DO UPDATE, jadi menyimpan rule dengan id yang sama memperbarui kolom selain created_at src-tauri/src/db/mod.rs:635-66123. delete_port_forward menghapus baris lalu menulis tombstone bertipe 'port_forward', sehingga penghapusan ikut terbawa saat sinkronisasi src-tauri/src/db/mod.rs:663-67324. Perilaku tombstone dibahas di Backup, Sync Relay, dan API Server.
⚠️
forward_typetersimpan dan divalidasi sebagai nilailocal,remote, ataudynamicsrc-tauri/src/db/models.rs:164-18825, tetapi jalur eksekusi hanya membuka satu jenis tunnel.tunnel_startmemanggilstart_local_forwardtanpa membacarule.forward_type, danstart_local_forwardselalu melakukanTcpListener::bindpadalocal_address:local_portsrc-tauri/src/commands/mod.rs:1230-126613. Rule bertiperemoteataudynamictetap tersimpan, tetapi tidak mengubah arah penerusan.
⚠️
local_addressmenentukan antarmuka bind. Nilai bawaan127.0.0.1hanya menerima koneksi dari mesin sendiri, sedangkan nilai seperti0.0.0.0membuka port itu ke seluruh jaringan, danTcpListener::bindtidak memeriksa siapa pun yang datang src-tauri/src/tunnel/mod.rs:95-996. Periksa kembali fieldBind IPsebelum menyalakan tunnel di mesin yang terhubung ke jaringan bersama.
PortForwardRule yang dikirim ke frontend membawa sembilan field: id, host_id, label, forward_type, local_address, local_port, remote_address, remote_port, dan created_at; PortForwardInput adalah versi tanpa id dan created_at src-tauri/src/db/models.rs:190-21226.