internal/server/layer4_proxies_test.go
Rama main · réplica de lectura del repositorio original.
// 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))
}
}