The new cmd/tunnel client keeps the API URL and tunnel identity in ~/.config/tunnel/config.json (0600), provisions the key/CSR and certificate, terminates TLS, proxies to local routes, reloads routes without a restart, and runs as a background service. `tunnel import` takes over an existing official CLI profile, preserving its tunnel ID, token, key and certificate. Server hardening: per-IP and per-tunnel rate limits for creates and binds, a CA cool-down after repeated ACME failures, a startup sweep for stale _acme-challenge records, admin metrics, a 60-second half-close timeout, and separate prioritized control / ordered data bridge queues (with `end` ordered after the final bytes). The dashboard shows the new metrics.
212 lines
6.2 KiB
Go
212 lines
6.2 KiB
Go
package client
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestNormalizeAPI(t *testing.T) {
|
|
cases := map[string]string{
|
|
"tunnel.tzgyn.dev": "https://tunnel.tzgyn.dev",
|
|
"https://tunnel.tzgyn.dev/": "https://tunnel.tzgyn.dev",
|
|
"http://127.0.0.1:8080": "http://127.0.0.1:8080",
|
|
}
|
|
for input, want := range cases {
|
|
got, err := NormalizeAPI(input)
|
|
if err != nil || got != want {
|
|
t.Errorf("NormalizeAPI(%q) = %q, %v; want %q", input, got, err, want)
|
|
}
|
|
}
|
|
if _, err := NormalizeAPI("ftp://example.com"); err == nil {
|
|
t.Error("ftp should be rejected")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeTarget(t *testing.T) {
|
|
cases := map[string]string{
|
|
"3000": "127.0.0.1:3000",
|
|
":3000": "127.0.0.1:3000",
|
|
"127.0.0.1:4096": "127.0.0.1:4096",
|
|
"localhost:8080": "localhost:8080",
|
|
"[::1]:3000": "[::1]:3000",
|
|
}
|
|
for input, want := range cases {
|
|
got, err := NormalizeTarget(input)
|
|
if err != nil || got != want {
|
|
t.Errorf("NormalizeTarget(%q) = %q, %v; want %q", input, got, err, want)
|
|
}
|
|
}
|
|
for _, bad := range []string{"", "http://x", "localhost", "127.0.0.1:0", "127.0.0.1:99999"} {
|
|
if _, err := NormalizeTarget(bad); err == nil {
|
|
t.Errorf("NormalizeTarget(%q) should fail", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConfigRoundTrip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "config.json")
|
|
cfg := &Config{API: "https://tunnel.example", Routes: map[string]Route{}}
|
|
if err := cfg.AddRoute("api", "4096", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cfg.Save(path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info.Mode().Perm() != 0o600 {
|
|
t.Errorf("mode = %o, want 600", info.Mode().Perm())
|
|
}
|
|
loaded, err := Load(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if loaded.API != cfg.API || loaded.Routes["api"].Target != "127.0.0.1:4096" {
|
|
t.Errorf("round trip: %+v", loaded)
|
|
}
|
|
}
|
|
|
|
func TestEnsureIdentityReusesCSR(t *testing.T) {
|
|
paths := ConfigPaths(filepath.Join(t.TempDir(), "config.json"))
|
|
key1, csr1, err := EnsureIdentity(paths, "abc.tunnel.example")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
key2, csr2, err := EnsureIdentity(paths, "abc.tunnel.example")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(key1) != string(key2) || string(csr1) != string(csr2) {
|
|
t.Error("identity was regenerated on the second call")
|
|
}
|
|
if !csrMatches(csr1, "abc.tunnel.example") {
|
|
t.Error("CSR does not match the hostname")
|
|
}
|
|
}
|
|
|
|
func TestRouteForSNI(t *testing.T) {
|
|
host := "abc.tunnel.example"
|
|
cases := map[string]string{
|
|
host: "@",
|
|
"api." + host: "api",
|
|
"API." + host: "api",
|
|
"api." + host + ".": "api",
|
|
"a.b." + host: "",
|
|
"other.example": "",
|
|
"api.abc.tunnel.example.x": "",
|
|
}
|
|
for sni, want := range cases {
|
|
if got := routeForSNI(host, sni); got != want {
|
|
t.Errorf("routeForSNI(%q) = %q, want %q", sni, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReadOfficialRoutes(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "coolify.toml")
|
|
body := `[routes]
|
|
api = "127.0.0.1:4096"
|
|
web = { target = "localhost:3000", proxy_protocol = "v2" }
|
|
`
|
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
routes, err := readOfficialRoutes(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if routes["api"].Target != "127.0.0.1:4096" {
|
|
t.Errorf("api = %+v", routes["api"])
|
|
}
|
|
if routes["web"].Target != "localhost:3000" || routes["web"].ProxyProtocol != "v2" {
|
|
t.Errorf("web = %+v", routes["web"])
|
|
}
|
|
}
|
|
|
|
func TestImportOfficialProfile(t *testing.T) {
|
|
root := t.TempDir()
|
|
dataHome := filepath.Join(root, "data")
|
|
configHome := filepath.Join(root, "config")
|
|
t.Setenv("XDG_DATA_HOME", dataHome)
|
|
t.Setenv("XDG_CONFIG_HOME", configHome)
|
|
|
|
sourceDir := filepath.Join(dataHome, "opentunnel", "coolify")
|
|
if err := os.MkdirAll(sourceDir, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
officialPaths := ConfigPaths(filepath.Join(root, "official", "config.json"))
|
|
keyPEM, _, err := EnsureIdentity(officialPaths, "abc.tunnel.example")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(sourceDir, "tunnel.json"), []byte(`{"id":"abc","hostname":"abc.tunnel.example","certificateExpiry":"2027-01-01T00:00:00Z"}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(sourceDir, "token"), []byte("tok\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(sourceDir, "private-key.pem"), keyPEM, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(sourceDir, "certificate.pem"), []byte("dummy"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(sourceDir, "chain.pem"), []byte("dummy"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(configHome, "opentunnel"), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(configHome, "opentunnel", "coolify.toml"), []byte("[routes]\napi = \"127.0.0.1:4096\"\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
target := filepath.Join(root, "client", "config.json")
|
|
result, err := Import(ImportOptions{Profile: "coolify", API: "https://tunnel.example", ConfigPath: target})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.TunnelID != "abc" || result.Hostname != "abc.tunnel.example" {
|
|
t.Errorf("result = %+v", result)
|
|
}
|
|
cfg, err := Load(target)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Token != "tok" || cfg.API != "https://tunnel.example" || cfg.Routes["api"].Target != "127.0.0.1:4096" {
|
|
t.Errorf("config = %+v", cfg)
|
|
}
|
|
if _, err := os.Stat(ConfigPaths(target).Key); err != nil {
|
|
t.Errorf("key not copied: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProxyHeaders(t *testing.T) {
|
|
if got := string(proxyV1("203.0.113.9:51234")); got != "PROXY TCP4 203.0.113.9 0.0.0.0 51234 443\r\n" {
|
|
t.Errorf("v1 = %q", got)
|
|
}
|
|
if got := string(proxyV1("nonsense")); got != "PROXY UNKNOWN\r\n" {
|
|
t.Errorf("v1 unknown = %q", got)
|
|
}
|
|
v2 := proxyV2("203.0.113.9:51234", "api.example")
|
|
sig := "\r\n\r\n\x00\r\nQUIT\n"
|
|
if !strings.HasPrefix(string(v2), sig) {
|
|
t.Fatalf("v2 signature missing")
|
|
}
|
|
if v2[12] != 0x21 || v2[13] != 0x11 {
|
|
t.Errorf("v2 version/family = %x %x", v2[12], v2[13])
|
|
}
|
|
if !strings.HasSuffix(string(v2), "api.example") {
|
|
t.Errorf("v2 TLV missing")
|
|
}
|
|
unknown := proxyV2("nonsense", "")
|
|
if unknown[12] != 0x20 {
|
|
t.Errorf("v2 local command = %x", unknown[12])
|
|
}
|
|
}
|