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:
2026-10-26 00:32:31 +08:00
parent cba7d2366b
commit ebae420ab1
27 changed files with 3745 additions and 56 deletions
+211
View File
@@ -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])
}
}