selfhosted-gateway: Tunnel Docker ke Internet Pakai WireGuard dan SNI, Tanpa Nyentuh Cloudflare

· Review

Kalau Anda pernah memasang Cloudflare Tunnel hanya karena rumah Anda di balik CGNAT dan port forward tidak mungkin, Anda tahu rasanya menyerahkan satu lapis kontrol ke pihak lain. DNS, sertifikat, dan jalur trafik Anda semua lewat edge mereka. Bukan masalah besar sampai Anda ingin trafik itu tidak melewati perusahaan mana pun.

selfhosted-gateway adalah jawaban repo orang lain untuk itu. Ini review terhadap proyek pihak ketiga milik user GitHub hintjen (image-nya dipublikasikan di Docker Hub dengan namespace fractalnetworks), bukan produk kami. Yang saya lakukan adalah membaca source-nya langsung di branch main, bukan mengutip README, lalu melaporkan apa yang benar-benar ada di dalamnya, termasuk satu baris bash yang jelas salah tulis.

Ringkasnya: satu set container yang membangun tunnel WireGuard antara VPS publik dan server rumah, lalu memakai nginx dengan ssl_preread untuk mengarahkan trafik HTTPS berdasarkan SNI ke container yang tepat. Tidak ada kode aplikasi sama sekali.

Bentuk repo

Saat artikel ini ditulis, metadata API repo mencatat 1.740 stars, 87 fork, bahasa utama Shell, lisensi AGPL-3.0, ukuran repo hanya 116 KB, homepage gethomerun.app, dan default branch main. Commit terakhir bertanggal Maret 2025, jadi repo ini sudah sekitar 19 bulan tidak disentuh kode-nya, dengan 8 issue terbuka.

Isi tree-nya kecil sekali. File terbesar selain LICENSE adalah README 11 KB. Di bawah src/ hanya ada empat direktori:

+-- client-link  # WireGuard instance untuk client + TLS termination via Caddy
+-- create-link  # CLI script untuk membangun link
+-- gateway      # NGINX reverse proxy untuk mendistribusikan request
+-- gateway-link # WireGuard instance untuk sisi gateway

Tidak ada Go, tidak ada Python, tidak ada API. Empat Dockerfile, beberapa skrip shell, dua template nginx, satu template Caddyfile. Itu seluruhnya.

Trik intinya: SNI jadi nama container

Bagian paling menarik ada di src/gateway/nginx.conf.template. Gateway menerima trafik di port 443, membaca nama server dari handshake TLS tanpa mendekripsi apa pun, lalu menerjemahkannya menjadi nama container Docker:

stream {  
    map $ssl_preread_server_name $targetBackend {
        ~^(?<app>.+?)?\.(?<subdomain>.+?)?\.(?<domain>.+)\.(?<tld>.+)$ $app-$subdomain-$domain-$tld:443;
        ~^(?<subdomain>.+?)?\.(?<domain>.+)\.(?<tld>.+)$ $subdomain-$domain-$tld:443;
        ~^(?<domain>.+)\.(?<tld>.+)$ $domain-$tld:443;
    } 
    proxy_protocol on;
    server {
        listen 443; 
        proxy_connect_timeout 1s;
        proxy_timeout 60s;
        resolver 127.0.0.11;
        proxy_pass $targetBackend;       
        ssl_preread on;
    }
}

Kuncinya adalah tiga regex bertingkat yang menangkap app, subdomain, domain, dan tld secara terpisah, lalu menyusunnya kembali jadi $app-$subdomain-$domain-$tld. Karena itu, aturan penamaan container jadi bebas dari konfigurasi tambahan: titik diganti tanda hubung. nginx.example.com harus jadi container bernama nginx-example-com. FQDN dengan empat label seperti app.sub.domain.tld juga didukung oleh regex pertama, yang menerima satu label opsional di depan.

Baris resolver 127.0.0.11 adalah detail yang mudah terlewat tapi wajib: itu DNS internal Docker, dan proxy_pass dengan variabel di dalam blok stream tidak akan bisa resolve nama container tanpa resolver eksplisit. Trafik HTTP polos ditangani blok http terpisah di http.conf.template dengan logika pengelompokan yang sama, plus endpoint /test yang mengembalikan target hasil pemetaan - berguna untuk debug.

WireGuard: dua alamat tetap, MTU 1380

Tunnel-nya sendiri sederhana dan angkanya hard-coded. Sisi gateway di src/gateway-link/entrypoint.sh memakai 10.0.0.1/24 di interface link0, listen port 18521, dan menerima peer allowed-ips 10.0.0.2/32. Sisi client di src/client-link/entrypoint.sh memakai 10.0.0.2/24 dengan persistent-keepalive 30.

Yang perlu diperhatikan adalah tabel NAT di sisi gateway:

iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 80 -j DNAT --to-destination 10.0.0.2:8080
iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 443 -j DNAT --to-destination 10.0.0.2:8443
iptables -t nat -A POSTROUTING -o link0 -p tcp --dport 8080 -j SNAT --to-source 10.0.0.1
iptables -t nat -A POSTROUTING -o link0 -p tcp --dport 8443 -j SNAT --to-source 10.0.0.1

Jadi port 80 di gateway tidak diarahkan ke port 80 client, tapi ke 8080. Port 443 diarahkan ke 8443. Ini bukan salah tulis: di sisi client, Caddy dikonfigurasi mendengarkan di port tidak lazim itu lewat Caddyfile.template:

{
    http_port 8080
    https_port 8443
    servers {
        listener_wrappers {
            proxy_protocol
            tls
        }
    }
}

listener_wrappers dengan proxy_protocol inilah yang membuat IP asli pengunjung sampai ke container Anda, bukan IP gateway. Dan tls sebagai wrapper berarti Caddy yang memegang sertifikat, memakai Automatic HTTPS-nya sendiri, sehingga sertifikat Let’s Encrypt atau ZeroSSL diterbitkan di sisi rumah, bukan di VPS. Gateway hanya memindahkan byte dan tidak pernah melihat plaintext. Itu desain privasi yang rapi.

MTU diset 1380 di kedua sisi karena overhead WireGuard di atas jalur internet biasa. Ada juga proxy UDP generik lewat socat di port 18522 dan 18523 untuk trafik non-HTTP, dan mode FORWARD_ONLY yang mengganti Caddy dengan socat supaya TLS termination bisa diserahkan ke reverse proxy yang sudah ada.

Bug yang saya temukan: 2>& 1>NUL

Ini temuan paling konkret dari pembacaan source. Di src/create-link/remote.sh ada baris:

docker rm -f $CONTAINER_ID 2>& 1>NUL

Niatnya jelas: membungkam output. Yang terjadi justru sebaliknya. Di bash, urutan redirection dibaca kiri ke kanan: 2>&1 menduplikasi stderr ke stdout yang sedang aktif, lalu 1>NUL memindahkan stdout ke sebuah file. Jadi stdout masuk ke file, sedangkan stderr tetap keluar ke terminal - kebalikan dari yang diinginkan.

Lebih buruk lagi, NUL adalah nama device Windows, bukan /dev/null. Di Linux, 1>NUL membuat file biasa bernama NUL di direktori kerja. Saya menguji polanya secara terpisah di shell untuk memastikan: stdout-nya benar-benar masuk ke file NUL, dan pesan stderr tetap muncul di layar. Jadi skrip ini bukan cuma gagal senyap, ia juga meninggalkan file sampah. Dampaknya kecil - hanya satu container sementara yang dihapus - tapi polanya menunjukkan skrip ini ditulis dan diuji di Windows atau WSL.

Kelemahan dan batasan

Selain bug di atas, ada beberapa hal yang harus Anda timbang jujur sebelum memakainya.

Repo-nya sudah lama tidak bergerak. Commit terakhir Maret 2025, dan homepage repo mengarah ke produk lain (gethomerun.app). Proyek ini tampak seperti pendahulu, bukan proyek yang sedang dirawat aktif. Issue terbuka soal “detect links created for the wrong Gateway” dan “setup with existing Nginx Proxy Manager” belum terselesaikan.

Kebutuhan port UDP-nya lebar. README meminta Anda membuka seluruh rentang dari cat /proc/sys/net/ipv4/ip_local_port_range ke internet, karena port WireGuard di gateway dipilih acak dari rentang ephemeral. Itu jauh lebih dari sekadar satu port UDP tetap, dan bagi sebagian orang itu justru alasan Cloudflare Tunnel terasa lebih sederhana.

Default snippet-nya tidak aman untuk langsung dipakai. Template link-compose-snippet.yml mengirim BASIC_AUTH: admin:admin dan TLS_INTERNAL: true. Artinya, kalau Anda copy-paste tanpa membaca, layanan Anda dilindungi password admin dan memakai sertifikat self-signed yang membuat browser memunculkan peringatan. Komentar di template memang menyuruh menghapusnya, tapi default tetap default.

Container butuh cap_add: NET_ADMIN, karena ia mengelola interface WireGuard dan aturan iptables. Ini tidak bisa dihindari dengan desain seperti ini, tapi berarti container itu memang punya wewenang jaringan tingkat tinggi di host Anda.

Satu gateway adalah satu titik gagal, dan AGPL-3.0 berarti jika Anda memodifikasi lalu menyajikannya sebagai layanan ke orang lain, source modifikasi itu harus tersedia.

Siapa yang cocok

Proyek ini cocok kalau Anda memenuhi semua ini: punya satu VPS kecil dengan IP publik, ingin semua trafik self-hosted tidak melewati pihak ketiga, nyaman membaca konfigurasi nginx dan iptables, dan tidak masalah menjalankan software yang tidak aktif dikembangkan. Karena tidak ada kode aplikasi sama sekali, permukaan serangannya juga tipis - yang perlu dipercaya hanya nginx, Caddy, dan WireGuard, tiga komponen yang sudah lama teruji.

Kalau Anda lebih butuh sesuatu yang aktif dirawat dan tidak ingin membuka rentang port UDP besar, Cloudflare Tunnel atau pendekatan reverse-proxy-over-VPN yang lebih baru mungkin lebih cocok. Tapi sebagai contoh cara kerja tunnel berbasis SNI, repo ini justru enak dibaca: 116 KB, tanpa abstraksi, dan semua triknya terlihat di dua file template.

Repo: github.com/hintjen/selfhosted-gateway - AGPL-3.0.