Ctrl K
ComunidadGuías…

internal/server/layer4_wrappers.go

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

Archivo original ↗Preguntar a KiBot
// SPDX-License-Identifier: Apache-2.0

package server

import (
	"encoding/json"
	"fmt"
	"log"
	"regexp"
	"strings"

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

// v2.61.0 (issue #126): Layer4 proxies that SHARE a port through a caddy-l4
// listener wrapper.
//
// A dedicated Layer4 server cannot take a port Caddy's HTTP servers already use
// (":443 is already bound"). A listener wrapper sits in front of an existing
// server's listener: it reads the first bytes of each connection, hands the ones
// that match a layer4 route (an SNI name, an SSH banner, RDP, PostgreSQL) to
// that route's handlers, and passes everything else on untouched — to Caddy's
// own TLS and HTTP handling. That is what lets SSH for ssh.example.com travel
// over :443 next to normal HTTPS. TCP only: wrapping a UDP packet connection is
// not offered.

// layer4WrapTargets maps the form's choice to the generated HTTP server.
var layer4WrapTargets = map[string]string{
	models.Layer4WrapHTTPS: "srv0",         // :443
	models.Layer4WrapHTTP:  "caddyui_http", // :80
}

var layer4SNIRe = regexp.MustCompile(`^(\*\.)?[a-z0-9]([a-z0-9._-]{0,251}[a-z0-9])?$`)

// validateLayer4Shared checks the shared-port fields and normalises them.
func validateLayer4Shared(p *models.Layer4Proxy) string {
	if models.NormalizeLayer4Protocol(p.Protocol) != models.Layer4ProtocolTCP {
		return "Sharing a port works for TCP only — use a dedicated listener for UDP."
	}
	p.Protocol = models.Layer4ProtocolTCP
	p.ListenAddr = ""
	if _, ok := layer4WrapTargets[p.WrapServer]; !ok {
		return "Choose which port to share: HTTPS (443) or HTTP (80)."
	}
	p.ListenPort = p.WrapPort()
	allowed := map[string]bool{models.Layer4MatchSSH: true, models.Layer4MatchRDP: true, models.Layer4MatchPostgres: true}
	if p.WrapServer == models.Layer4WrapHTTPS {
		allowed[models.Layer4MatchTLSSNI] = true
	} else {
		allowed[models.Layer4MatchHTTPHost] = true
	}
	if !allowed[p.MatchKind] {
		if p.WrapServer == models.Layer4WrapHTTPS {
			return "On the HTTPS port, match TLS SNI hostnames, or an SSH, RDP or PostgreSQL connection."
		}
		return "On the HTTP port, match HTTP hostnames, or an SSH, RDP or PostgreSQL connection."
	}
	if p.MatchKind == models.Layer4MatchTLSSNI || p.MatchKind == models.Layer4MatchHTTPHost {
		hosts := p.MatchHostList()
		if len(hosts) == 0 {
			return "Enter at least one hostname to match."
		}
		for _, h := range hosts {
			if !layer4SNIRe.MatchString(h) {
				return fmt.Sprintf("%q is not a valid hostname (a leading *. wildcard is allowed).", h)
			}
		}
		p.MatchHosts = strings.Join(hosts, ", ")
	} else {
		p.MatchHosts = ""
	}
	if p.TerminateTLS && p.MatchKind != models.Layer4MatchTLSSNI {
		p.TerminateTLS = false
	}
	return ""
}

// layer4MatcherFor returns the caddy-l4 matcher object for a shared proxy.
func layer4MatcherFor(p models.Layer4Proxy) map[string]any {
	hosts := make([]any, 0)
	for _, h := range p.MatchHostList() {
		hosts = append(hosts, h)
	}
	switch p.MatchKind {
	case models.Layer4MatchTLSSNI:
		return map[string]any{"tls": map[string]any{"sni": hosts}}
	case models.Layer4MatchHTTPHost:
		return map[string]any{"http": []any{map[string]any{"host": hosts}}}
	case models.Layer4MatchSSH:
		return map[string]any{"ssh": map[string]any{}}
	case models.Layer4MatchRDP:
		return map[string]any{"rdp": map[string]any{}}
	case models.Layer4MatchPostgres:
		return map[string]any{"postgres": map[string]any{}}
	}
	return nil
}

// buildLayer4ListenerWrappers returns, per generated HTTP server, the layer4
// listener wrapper holding one route per enabled shared proxy.
func buildLayer4ListenerWrappers(proxies []models.Layer4Proxy) map[string]map[string]any {
	routes := map[string][]any{}
	for _, p := range proxies {
		if !p.Enabled || !p.IsShared() {
			continue
		}
		server := layer4WrapTargets[p.WrapServer]
		matcher := layer4MatcherFor(p)
		if server == "" || matcher == nil {
			continue
		}
		var handle []any
		if p.TerminateTLS {
			handle = append(handle, map[string]any{"handler": "tls"})
		}
		handle = append(handle, map[string]any{
			"handler":   "proxy",
			"upstreams": []any{map[string]any{"dial": []any{"tcp/" + p.UpstreamDisplay()}}},
		})
		routes[server] = append(routes[server], map[string]any{
			"match":  []any{matcher},
			"handle": handle,
		})
	}
	out := map[string]map[string]any{}
	for server, r := range routes {
		out[server] = map[string]any{"wrapper": "layer4", "routes": r}
	}
	return out
}

// isLayer4Wrapper reports whether a live listener_wrappers entry is a layer4 one.
func isLayer4Wrapper(w any) bool {
	m, _ := w.(map[string]any)
	return m != nil && m["wrapper"] == "layer4"
}

func isTLSWrapper(w any) bool {
	m, _ := w.(map[string]any)
	return m != nil && m["wrapper"] == "tls"
}

// mergeListenerWrappers builds the listener_wrappers list for one HTTP server.
//
// Order matters: the first wrapper listed sees a new connection first. The
// layer4 wrapper has to read the RAW bytes, so it must come BEFORE the tls
// wrapper — and when no tls wrapper is listed Caddy applies TLS first, which
// leaves the layer4 matchers looking at decrypted nonsense (found on a real
// Caddy: "tls: first record does not look like a TLS handshake"). So on a TLS
// server (needsTLS) we list tls explicitly, right after ours. Other wrappers the
// operator configured (PROXY protocol, for example) keep their place ahead of
// ours: they must strip their header before layer4 reads the stream.
//
// dropOurs removes a layer4 wrapper CaddyUI wrote earlier (we own it); without
// it a live layer4 wrapper is left alone unless we are replacing it.
func mergeListenerWrappers(live []any, ours map[string]any, dropOurs, needsTLS bool) []any {
	var others []any
	var tls any
	for _, w := range live {
		switch {
		case isLayer4Wrapper(w) && (ours != nil || dropOurs):
			continue
		case isTLSWrapper(w):
			tls = w
		default:
			others = append(others, w)
		}
	}
	out := append([]any{}, others...)
	if ours != nil {
		out = append(out, ours)
		if tls == nil && needsTLS {
			tls = map[string]any{"wrapper": "tls"}
		}
	}
	if tls != nil {
		// Dropping ours must not leave behind the tls wrapper we added: a lone
		// tls entry is what Caddy does implicitly anyway.
		if ours != nil || len(others) > 0 || !dropOurs {
			out = append(out, tls)
		}
	}
	return out
}

func layer4WrapperSettingKey(serverID int64) string {
	return fmt.Sprintf("layer4_wrappers_managed_%d", serverID)
}

// managedWrapperServers lists the HTTP servers CaddyUI wrote a layer4 wrapper to
// on a previous sync — the only ones it may later clear.
func (s *Server) managedWrapperServers(serverID int64) map[string]bool {
	out := map[string]bool{}
	for _, name := range strings.Split(mustGetSetting(s.DB, layer4WrapperSettingKey(serverID)), ",") {
		if name = strings.TrimSpace(name); name != "" {
			out[name] = true
		}
	}
	return out
}

func (s *Server) saveManagedWrapperServers(serverID int64, set map[string]bool) {
	var names []string
	for _, n := range []string{"srv0", "caddyui_http"} {
		if set[n] {
			names = append(names, n)
		}
	}
	_ = models.SetSetting(s.DB, layer4WrapperSettingKey(serverID), strings.Join(names, ","))
}

// applyLayer4ListenerWrappers sets the wrappers on the proposed (pre-validation)
// config, mirroring what writeLayer4ListenerWrappers pushes.
func (s *Server) applyLayer4ListenerWrappers(cfg map[string]any, serverID int64, proxies []models.Layer4Proxy) {
	apps, _ := cfg["apps"].(map[string]any)
	httpApp, _ := apps["http"].(map[string]any)
	servers, _ := httpApp["servers"].(map[string]any)
	wrappers := buildLayer4ListenerWrappers(proxies)
	managed := s.managedWrapperServers(serverID)
	for _, name := range []string{"srv0", "caddyui_http"} {
		srv, ok := servers[name].(map[string]any)
		if !ok {
			continue
		}
		ours := wrappers[name]
		if ours == nil && !managed[name] {
			continue
		}
		live, _ := srv["listener_wrappers"].([]any)
		merged := mergeListenerWrappers(live, ours, managed[name], name == "srv0")
		if len(merged) == 0 {
			delete(srv, "listener_wrappers")
		} else {
			srv["listener_wrappers"] = merged
		}
	}
}

// writeLayer4ListenerWrappers pushes the wrappers to the live Caddy. Only
// listener_wrappers entries of kind layer4 that CaddyUI wrote itself are ever
// replaced or removed; wrappers set any other way are left exactly as they are.
func (s *Server) writeLayer4ListenerWrappers(serverID int64, proxies []models.Layer4Proxy) error {
	wrappers := buildLayer4ListenerWrappers(proxies)
	managed := s.managedWrapperServers(serverID)
	changed := false
	for _, name := range []string{"srv0", "caddyui_http"} {
		ours := wrappers[name]
		if ours == nil && !managed[name] {
			continue
		}
		serverPath := "/config/apps/http/servers/" + name
		srvLive, err := s.Caddy.FetchPath(serverPath)
		if err != nil {
			return err
		}
		if srvLive == nil { // that HTTP server does not exist (yet); nothing to wrap
			if ours != nil {
				log.Printf("layer4 shared proxy: server %s has no %s server yet — the wrapper is applied once it exists", fmt.Sprint(serverID), name)
			}
			continue
		}
		path := serverPath + "/listener_wrappers"
		liveRaw, err := s.Caddy.FetchPath(path)
		if err != nil {
			return err
		}
		live, _ := liveRaw.([]any)
		merged := mergeListenerWrappers(live, ours, managed[name], name == "srv0")
		switch {
		case len(merged) == 0 && liveRaw != nil:
			if err := s.Caddy.DeletePath(path); err != nil {
				return err
			}
		case len(merged) > 0 && !configValuesEqual(liveRaw, merged):
			if liveRaw == nil {
				if err := s.Caddy.PutPath(path, merged); err != nil {
					return err
				}
			} else if err := s.Caddy.PatchPath(path, merged); err != nil {
				return err
			}
		}
		if ours != nil {
			if !managed[name] {
				managed[name] = true
				changed = true
			}
		} else if managed[name] {
			delete(managed, name)
			changed = true
		}
	}
	if changed {
		s.saveManagedWrapperServers(serverID, managed)
	}
	return nil
}

// layer4WrappersJSON is a small helper for tests and logs.
func layer4WrappersJSON(w map[string]map[string]any) string {
	b, _ := json.Marshal(w)
	return string(b)
}