Ctrl K
ComunidadGuías…

internal/server/fleet_follow_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"
	"fmt"
	"net/http"
	"net/http/httptest"
	"net/url"
	"path/filepath"
	"reflect"
	"strconv"
	"strings"
	"testing"
	"time"

	appdb "github.com/X4Applegate/caddyui/internal/db"
	"github.com/X4Applegate/caddyui/internal/models"
)

// v2.68.3 (issue #150): "fleet settings are not being propagated". Checked on
// a real v2.68.2 with two Caddy nodes: a new host and its middleware profile
// did reach the automatic target, but
//   - a Settings save (e.g. the fleet-wide IP blocklist) re-synced only the
//     selected server, so the other nodes kept serving blocked addresses;
//   - disabling a host, or putting it in maintenance, with the quick toggle or
//     a bulk action left its copy live on every target;
//   - ticking an automatic target copied nothing that already existed, with
//     no word that "Sync from current" is needed for that.

type followFleet struct {
	s                    *Server
	db                   *sql.DB
	source, edge, other  int64
	sourceFake, edgeFake *layer4FakeAdmin
}

func newFollowFleet(t *testing.T) followFleet {
	t.Helper()
	conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = conn.Close() })
	mk := func(name string) (int64, *layer4FakeAdmin) {
		admin, fake := newLayer4FakeAdmin(t, `{"apps":{"http":{"servers":{"srv0":{"listen":[":443"],"routes":[]}}}}}`)
		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 := followFleet{s: newRenderingTestServer(t, conn), db: conn}
	f.source, f.sourceFake = mk("Source")
	f.edge, f.edgeFake = mk("Edge")
	f.other, _ = mk("Other") // managed, but not a deployment target
	setDefaultTargets(t, conn, f.source, f.edge)
	return f
}

func (f followFleet) createHost(t *testing.T, domain string, extra url.Values) *models.ProxyHost {
	t.Helper()
	form := url.Values{
		"domains": {domain}, "forward_scheme": {"http"},
		"forward_host": {"203.0.113.10"}, "forward_port": {"8080"}, "enabled": {"on"},
	}
	for k, v := range extra {
		form[k] = v
	}
	rec := httptest.NewRecorder()
	f.s.createProxyHost(rec, cookieReq(t, "/proxy-hosts", form, f.source))
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("create %s: want 303, got %d: %s", domain, rec.Code, rec.Body.String())
	}
	return f.hostOn(t, f.source, domain)
}

func (f followFleet) hostOn(t *testing.T, serverID int64, domain string) *models.ProxyHost {
	t.Helper()
	hosts, err := models.ListProxyHosts(f.db, serverID, 0, true, nil)
	if err != nil {
		t.Fatal(err)
	}
	for i := range hosts {
		if hosts[i].Domains == domain {
			return &hosts[i]
		}
	}
	return nil
}

// state is a host's enabled/maintenance flags for failure messages.
func state(p *models.ProxyHost) string {
	if p == nil {
		return "no copy"
	}
	return fmt.Sprintf("enabled=%v maintenance=%v", p.Enabled, p.MaintenanceMode)
}

func (fa *layer4FakeAdmin) loads() int {
	fa.mu.Lock()
	defer fa.mu.Unlock()
	return fa.loadCalls
}

func TestQuickTogglesFollowAutomaticDeploymentTargets(t *testing.T) {
	f := newFollowFleet(t)
	src := f.createHost(t, "app.example.com", nil)
	if c := f.hostOn(t, f.edge, "app.example.com"); c == nil || !c.Enabled {
		t.Fatalf("the automatic target did not get an enabled copy: %s", state(c))
	}

	idParam := strconv.FormatInt(src.ID, 10)
	rec := httptest.NewRecorder()
	f.s.toggleProxyHost(rec, withChiURLParam(cookieReq(t, "/proxy-hosts/x/toggle", nil, f.source), "id", idParam))
	if c := f.hostOn(t, f.edge, "app.example.com"); c == nil || c.Enabled {
		t.Fatalf("disabled on the source, but the copy on the target is %s", state(c))
	}

	rec = httptest.NewRecorder()
	f.s.toggleMaintenanceMode(rec, withChiURLParam(cookieReq(t, "/proxy-hosts/x/maintenance", nil, f.source), "id", idParam))
	if c := f.hostOn(t, f.edge, "app.example.com"); c == nil || !c.MaintenanceMode {
		t.Fatalf("maintenance mode did not reach the copy: %s", state(c))
	}
	if f.hostOn(t, f.other, "app.example.com") != nil {
		t.Error("a server that is not a deployment target received a copy")
	}
}

// A bulk action moves every copy too, and syncs each target once, not once
// per host.
func TestBulkActionsFollowTargetsWithOneSyncPerTarget(t *testing.T) {
	f := newFollowFleet(t)
	a := f.createHost(t, "a.example.com", nil)
	b := f.createHost(t, "b.example.com", nil)
	before := f.edgeFake.loads()

	form := url.Values{"action": {"disable"}, "ids[]": {strconv.FormatInt(a.ID, 10), strconv.FormatInt(b.ID, 10)}}
	rec := httptest.NewRecorder()
	f.s.bulkToggleProxyHosts(rec, cookieReq(t, "/proxy-hosts/bulk-toggle", form, f.source))
	for _, d := range []string{"a.example.com", "b.example.com"} {
		if c := f.hostOn(t, f.edge, d); c == nil || c.Enabled {
			t.Errorf("bulk disable: copy of %s on the target is %s, want disabled", d, state(c))
		}
	}
	if n := f.edgeFake.loads() - before; n != 1 {
		t.Errorf("the target was synced %d times for one bulk action of 2 hosts, want 1", n)
	}

	form.Set("action", "enable")
	rec = httptest.NewRecorder()
	f.s.bulkMaintenanceProxyHosts(rec, cookieReq(t, "/proxy-hosts/bulk-maintenance", form, f.source))
	for _, d := range []string{"a.example.com", "b.example.com"} {
		if c := f.hostOn(t, f.edge, d); c == nil || !c.MaintenanceMode {
			t.Errorf("bulk maintenance: copy of %s on the target is %s, want maintenance on", d, state(c))
		}
	}
}

func TestRedirectionAndAdvancedRouteTogglesFollowTargets(t *testing.T) {
	f := newFollowFleet(t)
	rec := httptest.NewRecorder()
	f.s.createRedirectionHost(rec, cookieReq(t, "/redirection-hosts", url.Values{
		"domains": {"old.example.com"}, "forward_scheme": {"https"},
		"forward_domain": {"new.example.com"}, "forward_http_code": {"301"}, "enabled": {"on"},
	}, f.source))
	redirOn := func(serverID int64) *models.RedirectionHost {
		rows, _ := models.ListRedirectionHosts(f.db, serverID, 0, true, nil)
		if len(rows) != 1 {
			t.Fatalf("server %d has %d redirections, want 1", serverID, len(rows))
		}
		return &rows[0]
	}
	src := redirOn(f.source)
	rec = httptest.NewRecorder()
	f.s.toggleRedirectionHost(rec, withChiURLParam(cookieReq(t, "/redirection-hosts/x/toggle", nil, f.source), "id", strconv.FormatInt(src.ID, 10)))
	if redirOn(f.edge).Enabled {
		t.Error("redirection disabled on the source stays enabled on the target")
	}

	raw := models.RawRoute{Label: "raw", Enabled: true, JSONData: `{"match":[{"host":["raw.example.com"]}],"handle":[{"handler":"static_response","status_code":200}]}`}
	id, err := models.CreateRawRoute(f.db, f.source, 0, &raw)
	if err != nil {
		t.Fatal(err)
	}
	raw.ID = id
	f.s.crossDeployRawRoute("test", f.source, &raw, []int64{f.edge})
	rec = httptest.NewRecorder()
	f.s.bulkToggleRawRoutes(rec, cookieReq(t, "/raw-routes/bulk-toggle", url.Values{"action": {"disable"}, "ids[]": {strconv.FormatInt(id, 10)}}, f.source))
	rows, _ := models.ListRawRoutes(f.db, f.edge, 0, true, nil)
	if len(rows) != 1 || rows[0].Enabled {
		t.Errorf("advanced route disabled on the source: %d copies on the target, first enabled=%v; want one, disabled", len(rows), len(rows) > 0 && rows[0].Enabled)
	}
}

func TestNodeLocalHostTogglesStayOffTargets(t *testing.T) {
	f := newFollowFleet(t)
	src := f.createHost(t, "db.internal.example.com", url.Values{"node_local": {"on"}})
	rec := httptest.NewRecorder()
	f.s.toggleProxyHost(rec, withChiURLParam(cookieReq(t, "/proxy-hosts/x/toggle", nil, f.source), "id", strconv.FormatInt(src.ID, 10)))
	if f.hostOn(t, f.edge, "db.internal.example.com") != nil {
		t.Error("toggling a node-local host deployed it to the target")
	}
}

func TestSettingsSavesResyncEveryManagedServer(t *testing.T) {
	f := newFollowFleet(t)
	if _, err := models.CreateCaddyServer(f.db, &models.CaddyServer{Name: "Monitor", AdminURL: "http://mon:2019", Type: models.CaddyServerTypeExternal}); err != nil {
		t.Fatal(err)
	}
	if got, want := f.s.settingsSyncServerIDs(f.edge), []int64{f.edge, f.source, f.other}; !reflect.DeepEqual(got, want) {
		t.Fatalf("settings sync order = %v, want %v (selected first, every managed server, no external)", got, want)
	}

	// End to end: the fleet-wide blocklist saved on Settings -> Security,
	// with Source selected, must reach the Edge Caddy too.
	f.createHost(t, "app.example.com", nil)
	rec := httptest.NewRecorder()
	f.s.postSettings(rec, cookieReq(t, "/settings", url.Values{
		"settings_section": {"security"}, "global_ip_blocklist": {"198.51.100.0/24"},
	}, f.source))
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("settings save: want 303, got %d: %s", rec.Code, rec.Body.String())
	}
	reached := func(fa *layer4FakeAdmin) bool {
		fa.mu.Lock()
		defer fa.mu.Unlock()
		for _, w := range fa.writes {
			if strings.Contains(w, "198.51.100.0/24") {
				return true
			}
		}
		return false
	}
	deadline := time.Now().Add(10 * time.Second)
	for !(reached(f.sourceFake) && reached(f.edgeFake)) && time.Now().Before(deadline) {
		time.Sleep(50 * time.Millisecond)
	}
	if !reached(f.sourceFake) || !reached(f.edgeFake) {
		t.Errorf("fleet-wide blocklist pushed to source=%v edge=%v, want both", reached(f.sourceFake), reached(f.edgeFake))
	}
}

func TestAddingAnAutomaticTargetSaysExistingResourcesNeedASync(t *testing.T) {
	f := newFollowFleet(t)
	setDefaultTargets(t, f.db, f.source) // none yet
	f.createHost(t, "app.example.com", nil)
	srv, _ := models.GetCaddyServer(f.db, f.source)

	update := func(targets ...int64) string {
		form := url.Values{"name": {"Source"}, "admin_url": {srv.AdminURL}, "type": {"managed"}}
		for _, id := range targets {
			form.Add("default_deploy_targets", strconv.FormatInt(id, 10))
		}
		rec := httptest.NewRecorder()
		f.s.updateServer(rec, withChiURLParam(postForm(t, "/servers/x", form), "id", strconv.FormatInt(f.source, 10)))
		if rec.Code != http.StatusSeeOther {
			t.Fatalf("update: want 303, got %d: %s", rec.Code, rec.Body.String())
		}
		loc, _ := url.QueryUnescape(rec.Header().Get("Location"))
		return loc
	}
	if loc := update(f.edge); !strings.Contains(loc, "The 1 that already exist on Source were not copied") || !strings.Contains(loc, "Sync from current on Edge") {
		t.Errorf("adding a target: redirect = %q, want the not-copied notice", loc)
	}
	if loc := update(f.edge); loc != "/servers" {
		t.Errorf("saving again with the same target: redirect = %q, want no notice", loc)
	}
}