// 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) }