Ctrl K
ComunidadGuías…

internal/server/layer4_proxies_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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))
	}
}