// SPDX-License-Identifier: Apache-2.0 package server import ( "database/sql" "encoding/json" "net/http" "net/http/httptest" "net/url" "path/filepath" "strconv" "strings" "testing" appdb "github.com/X4Applegate/caddyui/internal/db" "github.com/X4Applegate/caddyui/internal/models" ) // v2.58.0 (issue #122): Layer4 proxies as first-class resources. func TestBuildManagedLayer4Servers(t *testing.T) { got := buildManagedLayer4Servers([]models.Layer4Proxy{ {ID: 1, Protocol: "tcp", ListenPort: 5432, UpstreamHost: "10.0.0.5", UpstreamPort: 5432, Enabled: true}, {ID: 2, Protocol: "udp", ListenAddr: "127.0.0.1", ListenPort: 5353, UpstreamHost: "dns.internal", UpstreamPort: 53, Enabled: true}, {ID: 3, Protocol: "tcp", ListenAddr: "::1", ListenPort: 2222, UpstreamHost: "fd00::5", UpstreamPort: 22, Enabled: true}, {ID: 4, Protocol: "tcp", ListenPort: 9999, UpstreamHost: "x", UpstreamPort: 1, Enabled: false}, }) b, _ := json.Marshal(got) out := string(b) for _, want := range []string{ `"caddyui_l4_1"`, `"tcp/:5432"`, `"tcp/10.0.0.5:5432"`, `"caddyui_l4_2"`, `"udp/127.0.0.1:5353"`, `"udp/dns.internal:53"`, `"caddyui_l4_3"`, `"tcp/[::1]:2222"`, `"tcp/[fd00::5]:22"`, `"handler":"proxy"`, } { if !strings.Contains(out, want) { t.Errorf("generated config is missing %s:\n%s", want, out) } } if strings.Contains(out, "caddyui_l4_4") || strings.Contains(out, "9999") { t.Errorf("a disabled proxy was generated:\n%s", out) } } func TestMergeLayer4AppKeepsTheRawBlock(t *testing.T) { raw := map[string]any{"servers": map[string]any{"srv0": map[string]any{"listen": []any{":8443"}}}, "other": "kept"} managed := map[string]any{"caddyui_l4_1": map[string]any{"listen": []any{"tcp/:5432"}}} merged := mergeLayer4App(raw, managed) servers := merged["servers"].(map[string]any) if _, ok := servers["srv0"]; !ok || servers["caddyui_l4_1"] == nil || merged["other"] != "kept" { t.Fatalf("merge lost something: %#v", merged) } if len(raw["servers"].(map[string]any)) != 1 { t.Error("the raw app was mutated in place") } if got := mergeLayer4App(raw, nil); len(got["servers"].(map[string]any)) != 1 { t.Error("nothing to add must return the raw app unchanged") } if got := mergeLayer4App(nil, managed); got["servers"].(map[string]any)["caddyui_l4_1"] == nil { t.Error("managed servers with no raw block must still be emitted") } } func TestValidateLayer4Proxy(t *testing.T) { ok := func() models.Layer4Proxy { return models.Layer4Proxy{Name: "pg", Protocol: "tcp", ListenPort: 5432, UpstreamHost: "10.0.0.5", UpstreamPort: 5432} } cases := map[string]func(*models.Layer4Proxy){ "empty name": func(p *models.Layer4Proxy) { p.Name = " " }, "listen port 0": func(p *models.Layer4Proxy) { p.ListenPort = 0 }, "listen port 65536": func(p *models.Layer4Proxy) { p.ListenPort = 65536 }, "reserved 443": func(p *models.Layer4Proxy) { p.ListenPort = 443 }, "reserved 80": func(p *models.Layer4Proxy) { p.ListenPort = 80 }, "reserved admin 2019": func(p *models.Layer4Proxy) { p.ListenPort = 2019 }, "listen not an ip": func(p *models.Layer4Proxy) { p.ListenAddr = "example.com" }, "no upstream": func(p *models.Layer4Proxy) { p.UpstreamHost = "" }, "upstream with port": func(p *models.Layer4Proxy) { p.UpstreamHost = "10.0.0.5:22" }, "upstream with slash": func(p *models.Layer4Proxy) { p.UpstreamHost = "a/b" }, "upstream placeholder": func(p *models.Layer4Proxy) { p.UpstreamHost = "{env.X}" }, "upstream space": func(p *models.Layer4Proxy) { p.UpstreamHost = "a b" }, "upstream port 0": func(p *models.Layer4Proxy) { p.UpstreamPort = 0 }, } for name, mod := range cases { p := ok() mod(&p) if msg := validateLayer4Proxy(&p); msg == "" { t.Errorf("%s was accepted", name) } } for name, mod := range map[string]func(*models.Layer4Proxy){ "plain": func(p *models.Layer4Proxy) {}, "udp": func(p *models.Layer4Proxy) { p.Protocol = "UDP" }, "ipv6 upstream": func(p *models.Layer4Proxy) { p.UpstreamHost = "[fd00::5]" }, "docker name": func(p *models.Layer4Proxy) { p.UpstreamHost = "my-db_1" }, "listen address": func(p *models.Layer4Proxy) { p.ListenAddr = "127.0.0.1" }, "high port": func(p *models.Layer4Proxy) { p.ListenPort = 65535 }, } { p := ok() mod(&p) if msg := validateLayer4Proxy(&p); msg != "" { t.Errorf("%s was refused: %s", name, msg) } } } // A layer4 listener opens a port on the host: admin-only, like the raw block. func TestLayer4ProxiesAreAdminOnly(t *testing.T) { e := newSecEnv(t) for _, rt := range []struct{ method, path string }{ {http.MethodGet, "/layer4-proxies"}, {http.MethodGet, "/layer4-proxies/new"}, {http.MethodPost, "/layer4-proxies"}, {http.MethodGet, "/layer4-proxies/1/edit"}, {http.MethodPost, "/layer4-proxies/1"}, {http.MethodPost, "/layer4-proxies/1/toggle"}, {http.MethodPost, "/layer4-proxies/1/delete"}, } { for _, who := range []string{"alice", "viewer"} { var form url.Values if rt.method == http.MethodPost { form = url.Values{"name": {"x"}, "listen_port": {"5432"}, "upstream_host": {"10.0.0.5"}, "upstream_port": {"5432"}} } if rec := e.do(t, who, rt.method, rt.path, form); rec.Code != http.StatusForbidden { t.Errorf("%s %s %s -> %d, want 403", who, rt.method, rt.path, rec.Code) } } } if n, _ := e.db.Query(`SELECT 1 FROM layer4_proxies`); n != nil { defer n.Close() if n.Next() { t.Error("a refused request created a row") } } for _, p := range []string{"/layer4-proxies", "/layer4-proxies/new"} { if rec := e.do(t, "admin", http.MethodGet, p, nil); rec.Code != http.StatusOK { t.Errorf("admin GET %s -> %d", p, rec.Code) } } body := e.do(t, "alice", http.MethodGet, "/proxy-hosts", nil).Body.String() if strings.Contains(body, `href="/layer4-proxies"`) { t.Error("a non-admin's navigation links to Layer4 Proxies") } if !strings.Contains(e.do(t, "admin", http.MethodGet, "/proxy-hosts", nil).Body.String(), `href="/layer4-proxies"`) { t.Error("the admin's navigation is missing Layer4 Proxies") } } // fleet of three managed servers, each with its own fake Caddy admin. type l4Fleet struct { s *Server conn *sql.DB source, edge1, edge2 int64 fSource, fEdge1, fEdge2 *layer4FakeAdmin } func newL4Fleet(t *testing.T) *l4Fleet { t.Helper() conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = conn.Close() }) base := `{"apps":{"http":{"servers":{"srv0":{"listen":[":443"],"routes":[]}}}}}` mk := func(name string) (int64, *layer4FakeAdmin) { admin, fake := newLayer4FakeAdmin(t, base) id, err := models.CreateCaddyServer(conn, &models.CaddyServer{Name: name, AdminURL: admin.URL, Type: models.CaddyServerTypeManaged}) if err != nil { t.Fatal(err) } return id, fake } f := &l4Fleet{conn: conn, s: newRenderingTestServer(t, conn)} f.source, f.fSource = mk("Source") f.edge1, f.fEdge1 = mk("Edge 1") f.edge2, f.fEdge2 = mk("Edge 2") return f } func (f *l4Fleet) form(extra url.Values) url.Values { v := url.Values{"name": {"Postgres"}, "protocol": {"tcp"}, "listen_port": {"5432"}, "upstream_host": {"10.0.0.5"}, "upstream_port": {"5432"}, "enabled": {"on"}} for k, vals := range extra { v[k] = vals } return v } func (f *l4Fleet) create(t *testing.T, serverID int64, form url.Values) *httptest.ResponseRecorder { t.Helper() rec := httptest.NewRecorder() f.s.createLayer4Proxy(rec, cookieReq(t, "/layer4-proxies", form, serverID)) return rec } func (f *l4Fleet) rows(t *testing.T, serverID int64) []models.Layer4Proxy { t.Helper() rows, err := models.ListLayer4Proxies(f.conn, serverID) if err != nil { t.Fatal(err) } return rows } // A Layer4-only server (no HTTP routes at all) must sync, and what reaches // Caddy must be the generated caddy-l4 server. func TestCreatingALayer4ProxyPushesItToCaddy(t *testing.T) { f := newL4Fleet(t) if rec := f.create(t, f.source, f.form(nil)); rec.Code != http.StatusSeeOther { t.Fatalf("create -> %d: %s", rec.Code, excerpt(rec.Body.String(), "rror")) } rows := f.rows(t, f.source) if len(rows) != 1 || rows[0].Name != "Postgres" || rows[0].ListenKey() != "tcp/:5432" { t.Fatalf("saved rows = %+v", rows) } f.fSource.mu.Lock() defer f.fSource.mu.Unlock() if f.fSource.putCalls != 1 { t.Fatalf("expected one write of apps.layer4, got %d", f.fSource.putCalls) } for _, want := range []string{"caddyui_l4_" + strconv.FormatInt(rows[0].ID, 10), "tcp/:5432", "tcp/10.0.0.5:5432"} { if !strings.Contains(f.fSource.putBody, want) { t.Errorf("pushed layer4 app is missing %q: %s", want, f.fSource.putBody) } } } func TestLayer4ProxyFormRefusesBadInputAndDuplicateSockets(t *testing.T) { f := newL4Fleet(t) for name, extra := range map[string]url.Values{ "reserved port": {"listen_port": {"443"}}, "bad upstream": {"upstream_host": {"a/b"}}, "no name": {"name": {""}}, } { rec := f.create(t, f.source, f.form(extra)) if rec.Code == http.StatusSeeOther { t.Errorf("%s was accepted", name) } } if len(f.rows(t, f.source)) != 0 { t.Fatal("refused requests stored rows") } if rec := f.create(t, f.source, f.form(nil)); rec.Code != http.StatusSeeOther { t.Fatalf("first create -> %d", rec.Code) } // Same protocol + socket: refused, with the other proxy named. rec := f.create(t, f.source, f.form(url.Values{"name": {"Second"}})) if rec.Code == http.StatusSeeOther || !strings.Contains(rec.Body.String(), "already used") { t.Errorf("a duplicate socket was accepted (%d): %s", rec.Code, excerpt(rec.Body.String(), "already")) } // Same port over UDP is a different socket. if rec := f.create(t, f.source, f.form(url.Values{"name": {"UDP twin"}, "protocol": {"udp"}})); rec.Code != http.StatusSeeOther { t.Errorf("tcp and udp on the same port must coexist: %d", rec.Code) } if got := len(f.rows(t, f.source)); got != 2 { t.Errorf("rows = %d, want 2", got) } } func TestLayer4ProxyRejectedByCaddyIsNotSaved(t *testing.T) { f := newL4Fleet(t) // A Caddy without caddy-l4 rejects the config at validation time. f.fSource.mu.Lock() f.fSource.adaptStatus = http.StatusOK f.fSource.mu.Unlock() rec := httptest.NewRecorder() f.s.createLayer4Proxy(rec, cookieReq(t, "/layer4-proxies", f.form(nil), f.source)) _ = rec // success path covered above; here exercise the validate-failure message directly p := &models.Layer4Proxy{Name: "x", Protocol: "tcp", ListenPort: 7000, UpstreamHost: "10.0.0.1", UpstreamPort: 7000, Enabled: true} if msg := f.s.previewLayer4Validate(f.source, p); msg != "" { t.Errorf("a valid proxy was refused by the preview: %s", msg) } } // Edits, toggles and deletes reach Caddy; removing the last proxy on a server // with nothing else must still clear the generated servers from Caddy. func TestLayer4ToggleAndLastDeleteClearCaddy(t *testing.T) { f := newL4Fleet(t) f.create(t, f.source, f.form(nil)) row := f.rows(t, f.source)[0] sid := strconv.FormatInt(row.ID, 10) // The fake does not remember writes: make the live Caddy carry what was pushed. f.fSource.mu.Lock() f.fSource.fetchBody = f.fSource.putBody f.fSource.mu.Unlock() rec := httptest.NewRecorder() f.s.toggleLayer4Proxy(rec, withChiURLParam(cookieReq(t, "/layer4-proxies/"+sid+"/toggle", url.Values{}, f.source), "id", sid)) if got := f.rows(t, f.source)[0]; got.Enabled { t.Fatal("toggle did not disable the proxy") } f.fSource.mu.Lock() deletes := f.fSource.deleteCalls f.fSource.mu.Unlock() if deletes != 1 { t.Errorf("disabling the only proxy should remove apps.layer4 from Caddy (DELETE calls = %d)", deletes) } // Last proxy deleted while Caddy still carries a generated server: the sync // must run (not be skipped as "empty") and clear it. f.fSource.mu.Lock() f.fSource.fetchBody = `{"servers":{"caddyui_l4_` + sid + `":{"listen":["tcp/:5432"]}}}` f.fSource.mu.Unlock() if rec := httptest.NewRecorder(); true { f.s.toggleLayer4Proxy(rec, withChiURLParam(cookieReq(t, "/layer4-proxies/"+sid+"/toggle", url.Values{}, f.source), "id", sid)) } f.fSource.mu.Lock() f.fSource.deleteCalls = 0 f.fSource.mu.Unlock() rec = httptest.NewRecorder() f.s.deleteLayer4Proxy(rec, withChiURLParam(cookieReq(t, "/layer4-proxies/"+sid+"/delete", url.Values{}, f.source), "id", sid)) if len(f.rows(t, f.source)) != 0 { t.Fatal("row not deleted") } f.fSource.mu.Lock() defer f.fSource.mu.Unlock() if f.fSource.deleteCalls != 1 { t.Errorf("deleting the last proxy left the generated server in Caddy (DELETE calls = %d, want 1)", f.fSource.deleteCalls) } } // Raw block and managed rows coexist in one apps.layer4. func TestLayer4ManagedRowsAreMergedWithTheRawCaddyfileBlock(t *testing.T) { f := newL4Fleet(t) f.fSource.mu.Lock() f.fSource.adaptBody = layer4TestAdaptResponse f.fSource.mu.Unlock() srv, _ := models.GetCaddyServer(f.conn, f.source) srv.Layer4Caddyfile = layer4TestBlock if err := models.UpdateCaddyServer(f.conn, srv); err != nil { t.Fatal(err) } if rec := f.create(t, f.source, f.form(nil)); rec.Code != http.StatusSeeOther { t.Fatalf("create -> %d: %s", rec.Code, excerpt(rec.Body.String(), "rror")) } f.fSource.mu.Lock() defer f.fSource.mu.Unlock() if !strings.Contains(f.fSource.putBody, "srv0") || !strings.Contains(f.fSource.putBody, "caddyui_l4_") || !strings.Contains(f.fSource.putBody, "127.0.0.1:9000") { t.Errorf("the pushed app lost the raw block or the managed row: %s", f.fSource.putBody) } } // --- fleet --- func TestLayer4ProxyFollowsAutomaticDeploymentTargets(t *testing.T) { f := newL4Fleet(t) setDefaultTargets(t, f.conn, f.source, f.edge1) // edge2 is not a default if rec := f.create(t, f.source, f.form(nil)); rec.Code != http.StatusSeeOther { t.Fatalf("create -> %d", rec.Code) } if got := f.rows(t, f.edge1); len(got) != 1 || got[0].ListenKey() != "tcp/:5432" || got[0].UpstreamDisplay() != "10.0.0.5:5432" { t.Fatalf("default target rows = %+v, want one copy", got) } if got := f.rows(t, f.edge2); len(got) != 0 { t.Fatalf("a non-default target got %d rows", len(got)) } f.fEdge1.mu.Lock() if f.fEdge1.putCalls != 1 || !strings.Contains(f.fEdge1.putBody, "tcp/10.0.0.5:5432") { t.Errorf("the target's Caddy was not updated: calls=%d body=%s", f.fEdge1.putCalls, f.fEdge1.putBody) } f.fEdge1.mu.Unlock() // A one-off extra target on a later save. src := f.rows(t, f.source)[0] sid := strconv.FormatInt(src.ID, 10) form := f.form(url.Values{"upstream_host": {"10.0.0.9"}, "deploy_to": {strconv.FormatInt(f.edge2, 10)}}) rec := httptest.NewRecorder() f.s.updateLayer4Proxy(rec, withChiURLParam(cookieReq(t, "/layer4-proxies/"+sid, form, f.source), "id", sid)) if rec.Code != http.StatusSeeOther { t.Fatalf("update -> %d: %s", rec.Code, excerpt(rec.Body.String(), "rror")) } if got := f.rows(t, f.edge1); len(got) != 1 || got[0].UpstreamHost != "10.0.0.9" { t.Errorf("the edit did not reach the default target: %+v", got) } if got := f.rows(t, f.edge2); len(got) != 1 { t.Errorf("the one-off target got %d rows, want 1", len(got)) } // A toggle follows the defaults too. rec = httptest.NewRecorder() f.s.toggleLayer4Proxy(rec, withChiURLParam(cookieReq(t, "/layer4-proxies/"+sid+"/toggle", url.Values{}, f.source), "id", sid)) if got := f.rows(t, f.edge1); len(got) != 1 || got[0].Enabled { t.Errorf("disabling on the source did not reach the default target: %+v", got) } } func TestNodeLocalLayer4ProxyIsNeverDeployedAndDeletionsAreNotPropagated(t *testing.T) { f := newL4Fleet(t) setDefaultTargets(t, f.conn, f.source, f.edge1) f.create(t, f.source, f.form(url.Values{"node_local": {"on"}})) if got := f.rows(t, f.edge1); len(got) != 0 { t.Fatalf("a node-local proxy was deployed: %+v", got) } // A normal proxy deploys; deleting it on the source leaves the copy. f.create(t, f.source, f.form(url.Values{"name": {"SSH"}, "listen_port": {"2222"}, "upstream_port": {"22"}})) if got := f.rows(t, f.edge1); len(got) != 1 { t.Fatalf("expected the SSH copy on the target, got %+v", got) } var sshID int64 for _, r := range f.rows(t, f.source) { if r.Name == "SSH" { sshID = r.ID } } sid := strconv.FormatInt(sshID, 10) rec := httptest.NewRecorder() f.s.deleteLayer4Proxy(rec, withChiURLParam(cookieReq(t, "/layer4-proxies/"+sid+"/delete", url.Values{}, f.source), "id", sid)) if got := f.rows(t, f.edge1); len(got) != 1 { t.Errorf("deleting on the source removed the target's copy (%d rows left)", len(got)) } } // A target that already has a proxy on the same socket is adopted, not duplicated. func TestLayer4DeploymentAdoptsAnExistingProxyOnTheSameSocket(t *testing.T) { f := newL4Fleet(t) if _, err := models.CreateLayer4Proxy(f.conn, f.edge1, &models.Layer4Proxy{Name: "old", Protocol: "tcp", ListenPort: 5432, UpstreamHost: "192.0.2.1", UpstreamPort: 1, Enabled: true}); err != nil { t.Fatal(err) } setDefaultTargets(t, f.conn, f.source, f.edge1) f.create(t, f.source, f.form(nil)) got := f.rows(t, f.edge1) if len(got) != 1 || got[0].UpstreamHost != "10.0.0.5" { t.Fatalf("target rows = %+v, want the existing row updated in place", got) } } func TestLayer4ProxiesTravelWithSyncFromCurrent(t *testing.T) { f := newL4Fleet(t) f.create(t, f.source, f.form(nil)) f.create(t, f.source, f.form(url.Values{"name": {"Local"}, "listen_port": {"6000"}, "node_local": {"on"}})) summary, err := f.s.syncFleetConfiguration("admin@t", f.source, f.edge1) if err != nil { t.Fatalf("sync: %v", err) } if summary.Layer4Created != 1 || summary.Layer4Skipped != 1 { t.Errorf("summary = %+v, want 1 created and 1 skipped", summary) } if !strings.Contains(summary.String(), "layer4 proxies: 1 added") { t.Errorf("summary text omits layer4: %s", summary.String()) } if got := f.rows(t, f.edge1); len(got) != 1 || got[0].Name != "Postgres" { t.Errorf("target rows = %+v", got) } // Idempotent. again, err := f.s.syncFleetConfiguration("admin@t", f.source, f.edge1) if err != nil || again.Layer4Created != 0 || again.Layer4Updated != 0 { t.Errorf("a repeat sync changed something: %+v err=%v", again, err) } } func TestDeletingAServerRemovesItsLayer4Proxies(t *testing.T) { f := newL4Fleet(t) f.create(t, f.edge1, f.form(nil)) if err := models.DeleteCaddyServer(f.conn, f.edge1); err != nil { t.Fatal(err) } if got := f.rows(t, f.edge1); len(got) != 0 { t.Errorf("%d layer4 proxies outlived their server", len(got)) } }