// SPDX-License-Identifier: Apache-2.0 package server import ( "fmt" "log" "net" "net/http" "regexp" "strconv" "strings" "github.com/X4Applegate/caddyui/internal/caddy" "github.com/X4Applegate/caddyui/internal/models" "github.com/go-chi/chi/v5" ) // Layer4 proxies (v2.58.0, issue #122): TCP/UDP forwards managed as individual // resources. CaddyUI generates a caddy-l4 server per enabled row and merges // them into apps.layer4 together with the optional raw per-server Layer4 // Caddyfile block. Admin-only: a layer4 listener opens a port on the host and a // forward reaches whatever the host can reach. // layer4ServerPrefix names the generated caddy-l4 servers. Everything under // this prefix is owned by CaddyUI and rewritten on every sync. const layer4ServerPrefix = "caddyui_l4_" // buildManagedLayer4Servers turns the enabled rows into caddy-l4 servers. func buildManagedLayer4Servers(proxies []models.Layer4Proxy) map[string]any { out := map[string]any{} for _, p := range proxies { if !p.Enabled || p.IsShared() { // shared proxies ride an HTTP server's listener wrapper continue } listen := p.Protocol + "/" + p.ListenDisplay() dial := p.Protocol + "/" + p.UpstreamDisplay() out[fmt.Sprintf("%s%d", layer4ServerPrefix, p.ID)] = map[string]any{ "listen": []any{listen}, "routes": []any{map[string]any{ "handle": []any{map[string]any{ "handler": "proxy", "upstreams": []any{map[string]any{"dial": []any{dial}}}, }}, }}, } } return out } // mergeLayer4App adds the managed servers to the raw (Caddyfile-derived) // layer4 app. The raw app is returned unchanged when there is nothing to add. func mergeLayer4App(raw map[string]any, managed map[string]any) map[string]any { if len(managed) == 0 { return raw } app := map[string]any{} for k, v := range raw { app[k] = v } servers := map[string]any{} if existing, ok := raw["servers"].(map[string]any); ok { for k, v := range existing { servers[k] = v } } for k, v := range managed { servers[k] = v } app["servers"] = servers return app } // layer4AppFor builds the complete apps.layer4 for a server: the raw // Caddyfile block (adapted through the given client) plus the managed rows. func layer4AppFor(cl *caddy.Client, srv *models.CaddyServer, proxies []models.Layer4Proxy) (map[string]any, error) { raw, err := buildLayer4App(cl, srv.Layer4Caddyfile) if err != nil { return nil, err } return mergeLayer4App(raw, buildManagedLayer4Servers(proxies)), nil } // hasManagedLayer4Live reports whether the live config still carries servers // CaddyUI generated earlier — so removing the last proxy on an otherwise empty // server still clears them instead of being skipped by the empty-config guard. func hasManagedLayer4Live(cl *caddy.Client) bool { live, err := cl.FetchPath("/config/apps/layer4") if err != nil || live == nil { return false } app, _ := live.(map[string]any) servers, _ := app["servers"].(map[string]any) for name := range servers { if strings.HasPrefix(name, layer4ServerPrefix) { return true } } return false } var layer4HostnameRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9._-]{0,251}[A-Za-z0-9])?$`) // reservedLayer4Ports are ports a layer4 listener must not take: HTTP/HTTPS // (Caddy's own servers, including HTTP/3 on UDP 443) and the admin API. var reservedLayer4Ports = map[int]string{80: "HTTP", 443: "HTTPS", 2019: "the Caddy admin API"} // validateLayer4Proxy checks a proxy's fields, returning a user-facing message. func validateLayer4Proxy(p *models.Layer4Proxy) string { p.Name = strings.TrimSpace(p.Name) if p.Name == "" || len(p.Name) > 100 || strings.ContainsAny(p.Name, "\r\n\x00") { return "Name is required (up to 100 characters)." } p.Protocol = models.NormalizeLayer4Protocol(p.Protocol) p.ListenAddr = strings.Trim(strings.TrimSpace(p.ListenAddr), "[]") p.UpstreamHost = strings.Trim(strings.TrimSpace(p.UpstreamHost), "[]") if p.Mode == models.Layer4ModeShared { if msg := validateLayer4Shared(p); msg != "" { return msg } } else { p.Mode, p.WrapServer, p.MatchKind, p.MatchHosts, p.TerminateTLS = "", "", "", "", false if p.ListenAddr != "" && net.ParseIP(p.ListenAddr) == nil { return "Listen address must be empty (all interfaces) or an IP address." } if p.ListenPort < 1 || p.ListenPort > 65535 { return "Listen port must be between 1 and 65535." } if why, bad := reservedLayer4Ports[p.ListenPort]; bad { return fmt.Sprintf("Port %d is used by %s — pick another listen port, or switch to sharing that port below.", p.ListenPort, why) } } if p.UpstreamHost == "" { return "Upstream host is required." } if net.ParseIP(p.UpstreamHost) == nil && !layer4HostnameRe.MatchString(p.UpstreamHost) { return "Upstream host must be a hostname or an IP address (no ports, paths or spaces)." } if p.UpstreamPort < 1 || p.UpstreamPort > 65535 { return "Upstream port must be between 1 and 65535." } return "" } func parseLayer4Form(r *http.Request) *models.Layer4Proxy { _ = r.ParseForm() port := func(name string) int { n, _ := strconv.Atoi(strings.TrimSpace(r.FormValue(name))) return n } return &models.Layer4Proxy{ Name: r.FormValue("name"), Protocol: r.FormValue("protocol"), ListenAddr: r.FormValue("listen_addr"), ListenPort: port("listen_port"), UpstreamHost: r.FormValue("upstream_host"), UpstreamPort: port("upstream_port"), Enabled: r.FormValue("enabled") == "on", NodeLocal: r.FormValue("node_local") == "on", Notes: strings.TrimSpace(r.FormValue("notes")), // v2.61.0 (issue #126): shared-port mode. Mode: strings.TrimSpace(r.FormValue("mode")), WrapServer: strings.TrimSpace(r.FormValue("wrap_server")), MatchKind: strings.TrimSpace(r.FormValue("match_kind")), MatchHosts: strings.TrimSpace(r.FormValue("match_hosts")), TerminateTLS: r.FormValue("terminate_tls") == "on", } } // previewLayer4Validate asks Caddy to validate the config a sync would push // with p added or replaced, so a rejected proxy (for example a Caddy without // the caddy-l4 module) is refused at save time with Caddy's own message. // Returns "" when Caddy accepts it or cannot be reached. func (s *Server) previewLayer4Validate(serverID int64, p *models.Layer4Proxy) string { srv, err := models.GetCaddyServer(s.DB, serverID) if err != nil { return "" } proxies, _ := models.ListLayer4Proxies(s.DB, serverID) replaced := false for i := range proxies { if proxies[i].ID == p.ID && p.ID != 0 { proxies[i] = *p replaced = true } } if !replaced { proxies = append(proxies, *p) } cl := s.caddyForServer(serverID) app, err := layer4AppFor(cl, srv, proxies) if err != nil { return "Caddy rejected the layer4 configuration: " + err.Error() } current, _, err := cl.FetchConfig() if err != nil { return "" } proposed, err := deepCopyMap(current) if err != nil { return "" } applyLayer4App(proposed, app) s.applyLayer4ListenerWrappers(proposed, serverID, proxies) // v2.61.0 (issue #126) if err := cl.Validate(proposed); err != nil { return "Caddy rejected this layer4 proxy: " + err.Error() + " (Layer4 needs a Caddy build with the caddy-l4 module, such as applegater/caddyui-caddy.)" } return "" } // --- pages --- func (s *Server) listLayer4Proxies(w http.ResponseWriter, r *http.Request) { sid := s.currentServerID(r) rows, err := models.ListLayer4Proxies(s.DB, sid) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } srv, _ := models.GetCaddyServer(s.DB, sid) raw := "" if srv != nil { raw = strings.TrimSpace(srv.Layer4Caddyfile) } s.render(w, r, "layer4_proxies.html", map[string]any{ "User": s.currentUser(r), "Rows": rows, "Section": "layer4", "ServerID": sid, "HasRaw": raw != "", "Flash": r.URL.Query().Get("flash"), }) } func (s *Server) renderLayer4Form(w http.ResponseWriter, r *http.Request, p *models.Layer4Proxy, errMsg string) { s.render(w, r, "layer4_proxy_form.html", map[string]any{ "User": s.currentUser(r), "Row": p, "Error": errMsg, "Section": "layer4", "ServerID": s.currentServerID(r), "OtherServers": s.otherManagedServers(r), }) } func (s *Server) newLayer4Proxy(w http.ResponseWriter, r *http.Request) { s.renderLayer4Form(w, r, &models.Layer4Proxy{Protocol: models.Layer4ProtocolTCP, Enabled: true}, "") } func (s *Server) editLayer4Proxy(w http.ResponseWriter, r *http.Request) { p := s.layer4FromURL(w, r) if p == nil { return } s.renderLayer4Form(w, r, p, "") } // layer4FromURL loads the proxy for {id} and checks it belongs to the selected // server; it writes the error response and returns nil otherwise. func (s *Server) layer4FromURL(w http.ResponseWriter, r *http.Request) *models.Layer4Proxy { id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) p, err := models.GetLayer4Proxy(s.DB, id) if err != nil || p == nil || p.ServerID != s.currentServerID(r) { http.NotFound(w, r) return nil } return p } func (s *Server) saveLayer4Checks(w http.ResponseWriter, r *http.Request, p *models.Layer4Proxy) bool { if msg := validateLayer4Proxy(p); msg != "" { s.renderLayer4Form(w, r, p, msg) return false } sid := s.currentServerID(r) if other, err := models.Layer4ListenConflict(s.DB, sid, *p); err != nil { s.renderLayer4Form(w, r, p, "Could not check for conflicts: "+err.Error()) return false } else if other != nil { s.renderLayer4Form(w, r, p, fmt.Sprintf("%s %s is already used by %q on this server.", strings.ToUpper(p.Protocol), p.ListenDisplay(), other.Name)) return false } if p.Enabled { if msg := s.previewLayer4Validate(sid, p); msg != "" { s.renderLayer4Form(w, r, p, msg) return false } } return true } func (s *Server) createLayer4Proxy(w http.ResponseWriter, r *http.Request) { p := parseLayer4Form(r) deployTo := s.effectiveDeployTargets(s.currentServerID(r), parseDeployTo(r)) if !s.saveLayer4Checks(w, r, p) { return } sid := s.currentServerID(r) id, err := models.CreateLayer4Proxy(s.DB, sid, p) if err != nil { s.renderLayer4Form(w, r, p, err.Error()) return } p.ID, p.ServerID = id, sid _ = models.LogActivity(s.DB, sid, s.currentUserEmail(r), "layer4_create", fmt.Sprintf("layer4:%d", id), p.Name+" "+p.ListenKey(), true) s.trySyncCaddy(sid, false) if len(deployTo) > 0 { s.crossDeployLayer4Proxy(s.currentUserEmail(r), sid, p, deployTo) } http.Redirect(w, r, "/layer4-proxies", http.StatusSeeOther) } func (s *Server) updateLayer4Proxy(w http.ResponseWriter, r *http.Request) { existing := s.layer4FromURL(w, r) if existing == nil { return } p := parseLayer4Form(r) p.ID, p.ServerID = existing.ID, existing.ServerID deployTo := s.effectiveDeployTargets(s.currentServerID(r), parseDeployTo(r)) if !s.saveLayer4Checks(w, r, p) { return } if err := models.UpdateLayer4Proxy(s.DB, p); err != nil { s.renderLayer4Form(w, r, p, err.Error()) return } _ = models.LogActivity(s.DB, p.ServerID, s.currentUserEmail(r), "layer4_update", fmt.Sprintf("layer4:%d", p.ID), p.Name+" "+p.ListenKey(), true) s.trySyncCaddy(p.ServerID, false) if len(deployTo) > 0 { s.crossDeployLayer4Proxy(s.currentUserEmail(r), p.ServerID, p, deployTo) } http.Redirect(w, r, "/layer4-proxies", http.StatusSeeOther) } func (s *Server) toggleLayer4Proxy(w http.ResponseWriter, r *http.Request) { p := s.layer4FromURL(w, r) if p == nil { return } enabled, err := models.ToggleLayer4Proxy(s.DB, p.ID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } p.Enabled = enabled action := "layer4_disable" if enabled { action = "layer4_enable" } _ = models.LogActivity(s.DB, p.ServerID, s.currentUserEmail(r), action, fmt.Sprintf("layer4:%d", p.ID), p.Name, true) s.trySyncCaddy(p.ServerID, false) // A toggle is a change to the resource, so it follows the automatic // deployment targets like a save does. if targets := s.effectiveDeployTargets(p.ServerID, nil); len(targets) > 0 { s.crossDeployLayer4Proxy(s.currentUserEmail(r), p.ServerID, p, targets) } http.Redirect(w, r, "/layer4-proxies", http.StatusSeeOther) } func (s *Server) deleteLayer4Proxy(w http.ResponseWriter, r *http.Request) { p := s.layer4FromURL(w, r) if p == nil { return } note := s.noteFleetDeletion(models.FleetResourceLayer4, p.ID, p.Name) if err := models.DeleteLayer4Proxy(s.DB, p.ID); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } _ = models.LogActivity(s.DB, p.ServerID, s.currentUserEmail(r), "layer4_delete", fmt.Sprintf("layer4:%d", p.ID), p.Name+" "+p.ListenKey(), true) s.trySyncCaddy(p.ServerID, false) // Copies on other servers are removed only when the source server has // "Propagate deletions" enabled (v2.59.0, issue #120). s.propagateNotedDeletion(s.currentUserEmail(r), note) msg := "Deleted " + p.Name + "." if src, _ := models.GetCaddyServer(s.DB, p.ServerID); src == nil || !src.PropagateDeletions { msg += " Copies on other servers were not removed." } http.Redirect(w, r, "/layer4-proxies?flash="+urlQueryEscape(msg), http.StatusSeeOther) } func urlQueryEscape(v string) string { return strings.NewReplacer(" ", "+", "&", "%26", "?", "%3F", "#", "%23").Replace(v) } // --- fleet deployment --- // upsertFleetLayer4Proxy creates or updates the target server's copy of a // proxy, keyed through the durable source→target mapping and, failing that, // the same protocol + listen socket. func (s *Server) upsertFleetLayer4Proxy(sourceServerID, targetServerID int64, source models.Layer4Proxy) (fleetUpsertResult, error) { targetID, err := s.mappedFleetTarget(sourceServerID, models.FleetResourceLayer4, source.ID, targetServerID) if err != nil { return fleetUpsertResult{}, err } var existing *models.Layer4Proxy if targetID > 0 { existing, err = models.GetLayer4Proxy(s.DB, targetID) if err != nil { return fleetUpsertResult{}, err } } else { targets, err := models.ListLayer4Proxies(s.DB, targetServerID) if err != nil { return fleetUpsertResult{}, err } for i := range targets { if targets[i].ListenKey() == source.ListenKey() { existing = &targets[i] targetID = targets[i].ID break } } } copy := source copy.ID, copy.ServerID, copy.NodeLocal = 0, targetServerID, false created := existing == nil changed := true if existing != nil { copy.ID = existing.ID current := *existing current.CreatedAt, current.UpdatedAt = copy.CreatedAt, copy.UpdatedAt changed = current != copy } else if other, err := models.Layer4ListenConflict(s.DB, targetServerID, copy); err != nil { return fleetUpsertResult{}, err } else if other != nil { return fleetUpsertResult{}, fmt.Errorf("%s %s is already used on the target server", strings.ToUpper(copy.Protocol), copy.ListenDisplay()) } if created { targetID, err = models.CreateLayer4Proxy(s.DB, targetServerID, ©) } else if changed { err = models.UpdateLayer4Proxy(s.DB, ©) } if err != nil { return fleetUpsertResult{}, err } if err := models.SaveFleetDeployment(s.DB, sourceServerID, models.FleetResourceLayer4, source.ID, targetServerID, targetID); err != nil { return fleetUpsertResult{}, err } return fleetUpsertResult{ID: targetID, Created: created, Changed: changed}, nil } // crossDeployLayer4Proxy mirrors a proxy onto the given fleet targets, exactly // as crossDeployProxyHost does for hosts. A node-local proxy is never deployed. func (s *Server) crossDeployLayer4Proxy(actor string, sourceServerID int64, p *models.Layer4Proxy, serverIDs []int64) { s.fleetDeployMu.Lock() defer s.fleetDeployMu.Unlock() if p.NodeLocal { _ = models.LogActivity(s.DB, sourceServerID, actor, "layer4_cross_deploy", fmt.Sprintf("layer4:%d", p.ID), "skipped: proxy is marked node-local", false) return } for _, sid := range serverIDs { if _, _, err := s.validateFleetPair(sourceServerID, sid); err != nil { log.Printf("cross-deploy layer4 target %d: %v", sid, err) _ = models.LogActivity(s.DB, sourceServerID, actor, "layer4_cross_deploy", fmt.Sprintf("server:%d", sid), err.Error(), false) continue } result, err := s.upsertFleetLayer4Proxy(sourceServerID, sid, *p) if err != nil { log.Printf("cross-deploy layer4 to server %d: %v", sid, err) _ = models.LogActivity(s.DB, sid, actor, "layer4_cross_deploy", "layer4:new", p.Name+": "+err.Error(), false) continue } detail := "already current " + p.Name if result.Created { detail = "created " + p.Name } else if result.Changed { detail = "updated " + p.Name } _ = models.LogActivity(s.DB, sid, actor, "layer4_cross_deploy", fmt.Sprintf("layer4:%d", result.ID), detail, true) if result.Changed { if err := s.syncCaddy(sid, false); err != nil { log.Printf("cross-deploy layer4 sync server %d: %v", sid, err) } } } }