Add the native Go client and finish M3 hardening
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.
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user