// SPDX-License-Identifier: Apache-2.0 package server import ( "bytes" "context" "crypto/tls" "database/sql" "encoding/json" "errors" "fmt" "io" "net" "net/http" "strconv" "strings" "sync" "time" "github.com/X4Applegate/caddyui/internal/caddy" "github.com/X4Applegate/caddyui/internal/models" ) // --- Feature B: Upstream health checks --- type upstreamHealthResult struct { ID int64 `json:"id"` Domains string `json:"domains"` // Status is the port-level (TCP) health: "ok", "error", "unknown", or // "disabled". Sourced from Caddy's /reverse_proxy/upstreams (authoritative // since Caddy is on the upstream's Docker network); falls back to a // direct dial for public/dotted hostnames not yet in Caddy's upstream map. Status string `json:"status"` LatencyMS int64 `json:"latency_ms"` Error string `json:"error,omitempty"` // AppStatus is the end-to-end HTTP-response health: "ok", "degraded", // "down", "unknown", or "disabled". Sourced from the app-health poller, // which does an HTTPS GET against the public domain every 60s. This is // what catches "port open but app wedged" (e.g. DB connection stuck) — // something Status (TCP) alone can't see. AppStatus string `json:"app_status,omitempty"` AppCode int `json:"app_code,omitempty"` AppLatencyMS int64 `json:"app_latency_ms,omitempty"` AppError string `json:"app_error,omitempty"` } // postMyColorTheme — v2.12.27: persist the signed-in user's preferred // color theme to the DB so it follows the account across devices. The // picker in Settings POSTs here on change. Body is form-encoded with a // single `theme` field. Empty string and "default" both clear the // preference (use the default palette); "orange" picks the carbon-orange // palette; "forest", "rose", and "indigo" pick their respective palettes // (v2.15.0). Anything else returns 400 so we don't store junk that the // CSS layer wouldn't recognise. func (s *Server) postMyColorTheme(w http.ResponseWriter, r *http.Request) { u := s.currentUser(r) if u == nil { http.Error(w, "not signed in", http.StatusUnauthorized) return } if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } theme := strings.TrimSpace(r.FormValue("theme")) // Normalise "default" to empty string in storage so the column reads // as "no opinion" by default and we don't carry a magic value around. if theme == "default" { theme = "" } switch theme { case "", "orange", "forest", "rose", "indigo": // allowed default: http.Error(w, "unknown theme", http.StatusBadRequest) return } if err := models.UpdateUserColorTheme(s.DB, u.ID, theme); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // apiAIStatus — v2.11.15: reports whether AI assist is enabled and which // model is configured. Frontend uses this to decide whether to render the // floating AI button at page load. // // v2.12.36: model now reflects the active provider's selected model so the // chat-panel header reads "Claude (Sonnet)" / "OpenAI (gpt-4o)" / etc. // instead of always showing the Ollama model name. func (s *Server) apiAIStatus(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") enabled, _ := models.GetSetting(s.DB, settingAIEnabled) provider, model := activeAIProviderModel(s.DB) _ = json.NewEncoder(w).Encode(map[string]any{ "enabled": enabled == "1", "provider": provider, "model": model, }) } // activeAIProviderModel — v2.12.36: read the AI provider selector and return // the provider name + the model name for that provider (with fallbacks). // Used by /api/ai/status and by /api/ai/chat dispatch. func activeAIProviderModel(db *sql.DB) (provider, model string) { provider, _ = models.GetSetting(db, settingAIProvider) switch provider { case "ollama_cloud": model, _ = models.GetSetting(db, settingAIOllamaCloudModel) if strings.TrimSpace(model) == "" { model = "qwen3-coder:480b-cloud" } case "anthropic": model, _ = models.GetSetting(db, settingAIAnthropicModel) if strings.TrimSpace(model) == "" { model = "claude-haiku-4-5-20251001" } case "openai": model, _ = models.GetSetting(db, settingAIOpenAIModel) if strings.TrimSpace(model) == "" { model = "gpt-4o-mini" } default: // "ollama" or "" (legacy installs default to local Ollama) provider = "ollama" model, _ = models.GetSetting(db, settingAIOllamaModel) if strings.TrimSpace(model) == "" { model = "llama3.2:latest" } } return } // apiAIChat — v2.11.15: proxies a user prompt to the configured Ollama // /api/chat endpoint. Single-shot — no server-side conversation history. // Streaming is disabled (stream=false) so we can return the full message // in one JSON response. // // The system prompt frames Ollama as a CaddyUI assistant, so generic // model knowledge gets steered toward Caddy / reverse-proxy / DNS / TLS // answers without needing a fine-tuned model. // AI chat request-timeout bounds (issue #109). const ( defaultAIRequestTimeoutSec = 90 aiRequestTimeoutMinSec = 5 aiRequestTimeoutMaxSec = 3600 ) // aiRequestTimeout returns the per-chat-turn deadline. Admins can raise it under // Settings → AI assistant for slow local-inference rigs (large models, heavy CPU // offload); the stored value is clamped to // [aiRequestTimeoutMinSec, aiRequestTimeoutMaxSec] and falls back to // defaultAIRequestTimeoutSec when unset or invalid. func (s *Server) aiRequestTimeout() time.Duration { secs := defaultAIRequestTimeoutSec if raw := strings.TrimSpace(mustGetSetting(s.DB, settingAIRequestTimeoutSec)); raw != "" { if n, err := strconv.Atoi(raw); err == nil && n >= aiRequestTimeoutMinSec && n <= aiRequestTimeoutMaxSec { secs = n } } return time.Duration(secs) * time.Second } func (s *Server) apiAIChat(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") enabled, _ := models.GetSetting(s.DB, settingAIEnabled) if enabled != "1" { w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(map[string]string{"error": "AI assist is disabled — turn it on under Settings."}) return } // v2.12.10: accept multi-turn message arrays for conversation memory // while keeping back-compat with the single {message:""} shape. var body struct { Message string `json:"message"` Messages []map[string]string `json:"messages"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { _ = json.NewEncoder(w).Encode(map[string]string{"error": "could not parse JSON body"}) return } turns := body.Messages if len(turns) == 0 && strings.TrimSpace(body.Message) != "" { turns = []map[string]string{{"role": "user", "content": body.Message}} } if len(turns) == 0 { _ = json.NewEncoder(w).Encode(map[string]string{"error": "send {messages:[{role,content},...]} or {message:'...'}"}) return } // Cap conversation length so a long-running tab doesn't blow past the // model's context window. ~20 messages = 5–10 conversational rounds. if len(turns) > 20 { turns = turns[len(turns)-20:] } // v2.12.5: beefed-up system prompt. Small models (e.g. llama3.2:3b) were // hallucinating Caddy v1 trivia and inventing directives. Concrete // Caddyfile examples plus an explicit "say I don't know" rule helps. // v2.12.10: relaxed the "be concise" constraint and added an explicit // rule about respecting prior conversation turns. Custom system prompt // (settingAISystemPrompt) overrides this default when the user sets one // in Settings. defaultSystemPrompt := `You are an assistant inside CaddyUI, a web app for managing the Caddy reverse proxy. Caddy v2 ONLY — never reference Caddy v1. When the user asks for a Caddy config, default to a PRODUCTION-GRADE Caddyfile — not a 3-line minimum viable one. The user already knows the bare minimum is ` + "`" + `reverse_proxy backend:80` + "`" + `; what they want from you is a config that's actually ready to ship. That means: compression, security path blocking, security headers, sensible upstream timeouts, X-Forwarded-* request headers. Only strip back to the minimum when the user explicitly says "just the basics" or "minimal". The default production template: hostname.example.com { encode zstd gzip @blocked path /.env* /wp-admin* /wp-login* /phpmyadmin* /.git/* /xmlrpc.php respond @blocked 403 header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" X-Frame-Options "SAMEORIGIN" Referrer-Policy "strict-origin-when-cross-origin" X-XSS-Protection "1; mode=block" } reverse_proxy backend:8080 { header_up X-Forwarded-Host {host} header_up X-Forwarded-Proto {scheme} header_up X-Real-IP {remote_host} } } App-specific tweaks to apply on top of the default template: # Nextcloud — needs CalDAV/CardDAV well-known redirects + WebDAV: cloud.example.com { encode zstd gzip redir /.well-known/carddav /remote.php/dav 301 redir /.well-known/caldav /remote.php/dav 301 @blocked path /.env* /wp-admin* /wp-login* /phpmyadmin* /.git/* /xmlrpc.php respond @blocked 403 header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" Referrer-Policy "no-referrer" X-Frame-Options "SAMEORIGIN" } reverse_proxy nextcloud:80 { header_up X-Forwarded-Host {host} header_up X-Forwarded-Proto {scheme} header_up X-Real-IP {remote_host} } } # HTTPS upstream with self-signed cert + custom Host header (Apache vhost / Unifi): internal.example.com { encode zstd gzip @blocked path /.env* /wp-admin* /wp-login* /phpmyadmin* /.git/* /xmlrpc.php respond @blocked 403 header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" X-Frame-Options "SAMEORIGIN" Referrer-Policy "strict-origin-when-cross-origin" } reverse_proxy https://upstream:8443 { header_up Host internal.example.com header_up X-Forwarded-Host {host} header_up X-Forwarded-Proto {scheme} header_up X-Real-IP {remote_host} transport http { tls tls_insecure_skip_verify tls_server_name internal.example.com keepalive 30s keepalive_idle_conns 50 dial_timeout 5s response_header_timeout 30s } } } # WebSocket / SSE / long-poll backend (n8n, Grafana live, monitoring): app.example.com { encode zstd gzip header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" } reverse_proxy backend:5678 { header_up X-Forwarded-Host {host} header_up X-Forwarded-Proto {scheme} header_up X-Real-IP {remote_host} flush_interval -1 } } # 301/302 redirect (no proxying): old.example.com { redir https://new.example.com{uri} 301 } # Wildcard cert via DNS-01 (needs the caddy-dns/cloudflare plugin): *.example.com, example.com { tls { dns cloudflare {env.CF_API_TOKEN} } encode zstd gzip reverse_proxy backend:8080 { header_up X-Forwarded-Host {host} header_up X-Forwarded-Proto {scheme} header_up X-Real-IP {remote_host} } } After the Caddyfile, write 3-6 bullet points explaining what each block does and which CaddyUI form fields map to which directive. The user is filling in form fields next to this chat — they want to know "the encode block = the Compression toggle on the form". Rules: - Site blocks ALWAYS start with hostnames followed by a space and an opening brace. - NEVER invent directives. If you don't know the exact Caddy v2 directive name, say "I'm not certain — check https://caddyserver.com/docs" instead of guessing. - Output valid Caddyfile syntax — directives like ` + "`" + `reverse_proxy` + "`" + `, ` + "`" + `redir` + "`" + `, ` + "`" + `tls` + "`" + `, ` + "`" + `header` + "`" + `, ` + "`" + `handle` + "`" + `, ` + "`" + `handle_path` + "`" + `, ` + "`" + `respond` + "`" + `, ` + "`" + `file_server` + "`" + `, ` + "`" + `encode` + "`" + `, ` + "`" + `transport http` + "`" + `, ` + "`" + `header_up` + "`" + `, ` + "`" + `flush_interval` + "`" + `. Don't make up new ones. - Treat earlier messages in the conversation as binding context — when the user says "make one here" or "add to that", refer back to what was discussed instead of inventing an unrelated example. - Default to PRODUCTION-GRADE Caddyfiles. Strip back to the minimum only when the user explicitly says "minimal" / "just the basics" / "simplest" / "shortest". - Always include the X-Forwarded-Host / X-Forwarded-Proto / X-Real-IP header_up trio inside reverse_proxy blocks unless the user's app explicitly doesn't want them. - The user is editing a config in CaddyUI alongside this chat. When relevant, point them at form fields: Domain names, Forward Host/Port, Auto SSL toggle, Managed DNS picker, Upstream Host Header, etc. CADDYUI APP KNOWLEDGE — answer "where do I configure X" / "how does X work" / "does CaddyUI support X" using this map: PAGES (left sidebar nav): - Dashboard (/) — overview cards, traffic stats, all Caddy servers grid, last sync - Routes: - Proxy Hosts (/proxy-hosts) — primary feature; reverse_proxy table; search, bulk toggle, drag-reorder, status filter tabs (All/Enabled/Disabled/Maintenance), tag filter - Redirections (/redirection-hosts) — 301/302 redirects, path stripping, sunset dates, wildcard subdomain - Advanced (/raw-routes) — raw JSON or Caddyfile snippets for features the form doesn't expose - Config: - Certificates (/certificates) — upload PEM bundles; see ACME/managed certs; expiry tracking - Import from Caddy (/import) — pull existing routes from a running Caddy admin endpoint - Paste Caddyfile (/caddyfile-import) — convert pasted Caddyfile to UI rows - System: - Analytics (/analytics) — request counts, top hosts, status code breakdown, last-7-days, per-host drill-down - Live Traffic (/live-traffic) — real-time tail of incoming requests via Caddy's access log - Snapshots (/snapshots) — DB backup/restore + auto-snapshot rotation - Activity (/activity) — audit log of user actions - Caddy Config (/caddy-config) — view live JSON config from Caddy admin API - API (/api/docs) — public REST API reference - API Tokens (/api-tokens) — manage tokens for the public REST API - Caddy Servers (/servers) — manage multi-server setup; admin only - Users (/users) — manage user accounts; admin only - Groups (/groups) — bundle users for shared host visibility; admin only - Settings (/settings) — global app configuration SETTINGS SECTIONS (/settings — anchor links: #settings-general etc.): - General: site title, favicon URL, custom 404 HTML, global maintenance mode, periodic auto-sync interval, activity log retention days, globally stripped response headers, color theme (default / Carbon Orange — v2.12.22+, per-account v2.12.27+) - AI Assistant: enable toggle, AI provider selector (Ollama local / Ollama Cloud / Anthropic Claude / OpenAI-compatible), per-provider URL/key/model, custom system prompt (this is where the prompt YOU are reading lives — Settings → AI → Custom system prompt) - SMTP: outbound email for password reset / invite - Notifications: webhook URL for state-change events - Time zone: display tz for activity log - DNS / IPs: Cloudflare API token, server public IP override, public IP version (4/6/auto), trusted_proxies CIDRs - Captcha: Turnstile / reCAPTCHA for login form - Security: admin allowlist CIDRs, require 2FA, max login attempts, session days - Analytics: enable access logs (TCP-forward from Caddy to caddyui:9019) MULTI-SERVER: - Each Caddy backend is a row in caddy_servers; CaddyUI manages many at once - Server picker in top bar (v2.12.28+) switches active context — also reachable on mobile - "Sync Caddy" pushes the active server's config via Caddy admin /load - Settings are GLOBAL across all managed servers; proxy/redirect hosts are PER-server (server_id FK) ROLES: - admin: everything - user: own hosts + group-shared visibility (read-only on others') - view: read-only across the board FEATURES (broader than just proxy hosts): - Drag-to-reorder rows on proxy hosts list - Bulk enable/disable (checkbox + bulk action bar) - Fuzzy search filter (proxy hosts list — domains, forward_host, tags, notes) - Per-form search (inside proxy host edit — fuzzy match on labels/placeholders/help text; v2.12.30 hides empty
sections so only matches show) - Per-host maintenance mode + global maintenance mode (Settings) - Snapshots: manual + auto-rolling DB backup, restore from any snapshot - Activity log (audit trail with retention) - Dark/light/auto top-bar toggle + Carbon Orange color theme (Settings → Color theme — synced across devices via users.color_theme) - ⌘K / Ctrl+K global command palette: search across hosts, redirects, certs, raw routes - ? keyboard shortcut overlay - Public REST API at /api/v1/* with token auth (admin generates tokens) - AI Assistant (this) — backed by your choice of Ollama (local), Ollama Cloud, Anthropic Claude, or any OpenAI-compatible API; supports tool calling that proposes proxy_host / redirection creates and shows a confirmation card before applying PROXY HOST FORM (form structure roughly mirrored by collapsible
sections): - Identity & SSL: Domain names, Forward Scheme (HTTP/HTTPS), Forward Host, Forward Port, Auto SSL, Force SSL, HTTP/2, www redirect (to_www / to_bare), Certificate (auto-ACME or pick uploaded), Owner, Color tag - Managed DNS: pick provider (Cloudflare/etc), zone, record name; CaddyUI auto-creates the DNS record on save - TLS Certificate: load custom PEM, pick CA (Let's Encrypt / ZeroSSL / custom) - Routing: Path matcher (prefix/exact/regex) - Block common exploits: ON by default — blocks /.env, /wp-admin, /wp-login, /phpmyadmin, /.git, xmlrpc.php - Compression: Enable + gzip/zstd/brotli sub-toggles + min size KB + level + prefer gzip + exclude regex - Security headers: HSTS (subdomains/preload), nosniff, X-Frame-Options, Referrer-Policy, X-XSS-Protection, Permissions-Policy, CSP, CSP-Report-Only - Custom request/response headers (set + delete) + Strip request/response headers (comma list) - Forwarded headers: X-Forwarded-Host, X-Forwarded-Proto, X-Real-IP, X-Forwarded-Port, X-Forwarded-Path, etc. (many individual toggles) - Upstream TLS: Verify cert, Custom CA PEM, TLS server name from Host, TLS server name explicit, TLS min/max version, cipher suites, early data - Upstream Host Header: override the Host sent upstream (header_up Host XYZ) - Keepalive: Disable, Max idle conns per host, Max idle conns total, Idle timeout sec, Max lifetime sec, Probes - Timeouts: Dial timeout, Response header timeout, Request body read timeout, Read header timeout, TLS handshake timeout, Expect-continue timeout, Stream flush interval ms (-1 = SSE/WebSocket flush every byte) - Authentication: Basic auth users (bcrypt), Access list CIDRs, HTTP basic auth upstream - Load balancing: Extra upstreams, LB policy (random/round-robin/ip-hash/least-conn/cookie), active + passive health checks, LB cookie config - Maintenance mode: per-host toggle + Allowed IPs (CIDRs that bypass) - Notes & Tags (free text + comma-separated tags, tag clicks filter the list) - Advanced raw config: appended Caddyfile/JSON snippet for features outside the form When a user asks "how do I do X", first check this map. If X is in CaddyUI, point at the EXACT page → section → field. If X is genuinely not in the form, say so and recommend "Advanced raw config" as the escape hatch. Most Caddy directives ARE configurable through CaddyUI's proxy host form — do NOT tell the user "you have to manually edit the Caddyfile." Specifically these are all UI-supported: - encode (compression): toggle "Enable compression" + zstd/gzip/brotli sub-toggles + min size + level - Security path blocking (/.env, /wp-admin, /.git, etc.): "Block common exploits" toggle (always on by default in CaddyUI) - Security headers bundle (HSTS / X-Content-Type-Options / X-Frame-Options / Referrer-Policy / X-XSS-Protection / Permissions-Policy / CSP): "Security headers" bundle toggle on the host - Strip / set custom request and response headers: "Custom request headers" + "Custom response headers" + "Strip request headers" + "Strip response headers" fields - Upstream Host header override: "Upstream Host Header" field (header_up Host XYZ) - X-Real-IP / X-Forwarded-Host / X-Forwarded-Proto: enabled by default; toggles in the Forwarded Headers section - Upstream TLS (https:// upstreams): "Forward Scheme: HTTPS" + "Verify upstream TLS cert" toggle + "Upstream TLS server name from host" + custom CA PEM - Keepalive: "Keepalive max idle conns" + "Keepalive idle timeout sec" + "Disable keepalive" + "Max idle conns per host" - Timeouts: "Dial timeout sec" + "Response header timeout sec" + "Request body read timeout sec" + "Read header timeout sec" + "TLS handshake timeout sec" + "Expect-continue timeout sec" + "Stream flush interval ms" - Buffering / streaming: "Stream flush interval ms" (use -1 for SSE/streaming, 0 for default buffering) - Health check + load balancing: "Extra upstreams" + "LB policy" + active/passive health check fields When the user asks for "this Caddyfile" → produce form values, not "edit the file manually." If a feature genuinely isn't UI-supported (rare), say so explicitly and point them at the "Advanced config" raw-Caddyfile field as the escape hatch. If you are NOT sure about a specific Caddy directive or behavior, say so. Hallucinated config is worse than "I don't know." TOOLS: You can call tools that ACTUALLY CREATE resources in CaddyUI. The user sees a confirmation card with the parameters and clicks Apply before anything happens — you don't have to ask permission, just call the tool when appropriate. When to call ` + "`" + `create_proxy_host` + "`" + ` or ` + "`" + `create_redirection` + "`" + `: - "create a proxy host for X" / "set up X" / "add X" / "make a proxy for X" → create_proxy_host - "redirect old.com to new.com" / "set up a 301 from X" → create_redirection - "what's the Caddyfile for X" / "explain X" / "show me how X looks" → just write a Caddyfile snippet, DO NOT call a tool When you call a tool, do not also write a Caddyfile in the same response — the tool call is the response.` systemPrompt := defaultSystemPrompt if customPrompt, _ := models.GetSetting(s.DB, settingAISystemPrompt); strings.TrimSpace(customPrompt) != "" { systemPrompt = customPrompt } // v2.12.11: tool definitions sent on every chat turn. Models that // don't support tools (older / smaller ones) ignore the field; for // qwen2.5/llama3.1+/gemma2 / Claude / GPT-4 the model can decide to // emit tool_calls in its response, which the frontend renders as a // confirmation card. tools := aiToolDefinitions() // v2.12.36: dispatch to the configured provider's adapter. Adapters // normalize back to the existing {reply, tool_calls, model} contract // so the frontend doesn't need to know which backend answered. provider, model := activeAIProviderModel(s.DB) ctx, cancel := context.WithTimeout(r.Context(), s.aiRequestTimeout()) defer cancel() var ( reply string calls []frontendTC callErr error ) switch provider { case "anthropic": apiKey, _ := models.GetSetting(s.DB, settingAIAnthropicAPIKey) if strings.TrimSpace(apiKey) == "" { _ = json.NewEncoder(w).Encode(map[string]string{"error": "Anthropic API key is empty — set one under Settings → AI assistant."}) return } reply, calls, callErr = aiCallAnthropic(ctx, apiKey, model, systemPrompt, turns, tools) case "openai": apiKey, _ := models.GetSetting(s.DB, settingAIOpenAIAPIKey) if strings.TrimSpace(apiKey) == "" { _ = json.NewEncoder(w).Encode(map[string]string{"error": "OpenAI API key is empty — set one under Settings → AI assistant."}) return } base, _ := models.GetSetting(s.DB, settingAIOpenAIBaseURL) if strings.TrimSpace(base) == "" { base = "https://api.openai.com/v1" } reply, calls, callErr = aiCallOpenAI(ctx, base, apiKey, model, systemPrompt, turns, tools) case "ollama_cloud": apiKey, _ := models.GetSetting(s.DB, settingAIOllamaCloudAPIKey) if strings.TrimSpace(apiKey) == "" { _ = json.NewEncoder(w).Encode(map[string]string{"error": "Ollama Cloud API key is empty — set one under Settings → AI assistant."}) return } reply, calls, callErr = aiCallOllama(ctx, "https://ollama.com", apiKey, model, systemPrompt, turns, tools) default: // ollama (local) baseURL, _ := models.GetSetting(s.DB, settingAIOllamaURL) if strings.TrimSpace(baseURL) == "" { baseURL = "http://ollama:11434" } reply, calls, callErr = aiCallOllama(ctx, baseURL, "", model, systemPrompt, turns, tools) } if callErr != nil { _ = json.NewEncoder(w).Encode(map[string]string{"error": callErr.Error()}) return } _ = json.NewEncoder(w).Encode(map[string]any{ "reply": strings.TrimSpace(reply), "tool_calls": calls, "model": model, "provider": provider, }) } // frontendTC — v2.12.11: tool-call shape returned to the chat panel for the // confirmation-card render. Hoisted out of apiAIChat in v2.12.36 so the // per-provider adapters can return it directly. type frontendTC struct { Name string `json:"name"` Args map[string]any `json:"args"` } // aiCallOllama — v2.12.36: send a chat turn to an Ollama-compatible /api/chat // endpoint. apiKey is empty for local Ollama and a bearer token for Ollama // Cloud (https://ollama.com). The on-the-wire schema is identical otherwise. // // Returns the assistant's text reply, any tool calls the model emitted, and // an error normalized to mention the upstream by name. func aiCallOllama(ctx context.Context, baseURL, apiKey, model, systemPrompt string, turns []map[string]string, tools []map[string]any) (string, []frontendTC, error) { finalMessages := make([]map[string]string, 0, len(turns)+1) finalMessages = append(finalMessages, map[string]string{"role": "system", "content": systemPrompt}) finalMessages = append(finalMessages, turns...) payload := map[string]any{ "model": model, "stream": false, "messages": finalMessages, "tools": tools, } pb, _ := json.Marshal(payload) req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(baseURL, "/")+"/api/chat", bytes.NewReader(pb)) if err != nil { return "", nil, fmt.Errorf("build request: %w", err) } req.Header.Set("Content-Type", "application/json") if apiKey != "" { req.Header.Set("Authorization", "Bearer "+apiKey) } resp, err := http.DefaultClient.Do(req) if err != nil { return "", nil, fmt.Errorf("ollama: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { raw, _ := io.ReadAll(resp.Body) return "", nil, fmt.Errorf("ollama %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) } var out struct { Message struct { Content string `json:"content"` ToolCalls []struct { Function struct { Name string `json:"name"` Arguments map[string]any `json:"arguments"` } `json:"function"` } `json:"tool_calls"` } `json:"message"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return "", nil, fmt.Errorf("decode ollama response: %w", err) } calls := make([]frontendTC, 0, len(out.Message.ToolCalls)) for _, tc := range out.Message.ToolCalls { calls = append(calls, frontendTC{Name: tc.Function.Name, Args: tc.Function.Arguments}) } return out.Message.Content, calls, nil } // aiCallAnthropic — v2.12.36: send a chat turn to the Anthropic Messages API. // Anthropic's schema differs from Ollama/OpenAI in three notable ways: // - system prompt is a TOP-LEVEL field, not a system message // - tools use `input_schema` not `parameters`, and have no outer `function` // wrapper // - tool calls come back as content blocks of type "tool_use" func aiCallAnthropic(ctx context.Context, apiKey, model, systemPrompt string, turns []map[string]string, tools []map[string]any) (string, []frontendTC, error) { // Translate the Ollama-flavoured tool definitions to Anthropic's shape. anthroTools := make([]map[string]any, 0, len(tools)) for _, t := range tools { fn, ok := t["function"].(map[string]any) if !ok { continue } anthroTools = append(anthroTools, map[string]any{ "name": fn["name"], "description": fn["description"], "input_schema": fn["parameters"], }) } payload := map[string]any{ "model": model, "max_tokens": 4096, "system": systemPrompt, "messages": turns, } if len(anthroTools) > 0 { payload["tools"] = anthroTools } pb, _ := json.Marshal(payload) req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.anthropic.com/v1/messages", bytes.NewReader(pb)) if err != nil { return "", nil, fmt.Errorf("build request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("x-api-key", apiKey) req.Header.Set("anthropic-version", "2023-06-01") resp, err := http.DefaultClient.Do(req) if err != nil { return "", nil, fmt.Errorf("anthropic: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { raw, _ := io.ReadAll(resp.Body) return "", nil, fmt.Errorf("anthropic %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) } var out struct { Content []struct { Type string `json:"type"` Text string `json:"text"` Name string `json:"name"` Input map[string]any `json:"input"` } `json:"content"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return "", nil, fmt.Errorf("decode anthropic response: %w", err) } var ( textParts []string calls []frontendTC ) for _, c := range out.Content { switch c.Type { case "text": textParts = append(textParts, c.Text) case "tool_use": calls = append(calls, frontendTC{Name: c.Name, Args: c.Input}) } } return strings.Join(textParts, "\n"), calls, nil } // aiCallOpenAI — v2.12.36: send a chat turn to an OpenAI-compatible // /chat/completions endpoint (also covers OpenRouter, Groq, Together, vLLM, // LM Studio — anything that exposes the OpenAI schema). // // The tool-definition shape matches Ollama's so we reuse it as-is. The // quirk: tool_calls.function.arguments comes back as a JSON STRING, not an // object — needs an extra Unmarshal pass. func aiCallOpenAI(ctx context.Context, baseURL, apiKey, model, systemPrompt string, turns []map[string]string, tools []map[string]any) (string, []frontendTC, error) { finalMessages := make([]map[string]string, 0, len(turns)+1) finalMessages = append(finalMessages, map[string]string{"role": "system", "content": systemPrompt}) finalMessages = append(finalMessages, turns...) payload := map[string]any{ "model": model, "messages": finalMessages, } if len(tools) > 0 { payload["tools"] = tools } pb, _ := json.Marshal(payload) req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(baseURL, "/")+"/chat/completions", bytes.NewReader(pb)) if err != nil { return "", nil, fmt.Errorf("build request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+apiKey) resp, err := http.DefaultClient.Do(req) if err != nil { return "", nil, fmt.Errorf("openai: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { raw, _ := io.ReadAll(resp.Body) return "", nil, fmt.Errorf("openai %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) } var out struct { Choices []struct { Message struct { Content string `json:"content"` ToolCalls []struct { Function struct { Name string `json:"name"` Arguments string `json:"arguments"` // JSON-encoded string per OpenAI spec } `json:"function"` } `json:"tool_calls"` } `json:"message"` } `json:"choices"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return "", nil, fmt.Errorf("decode openai response: %w", err) } if len(out.Choices) == 0 { return "", nil, nil } msg := out.Choices[0].Message calls := make([]frontendTC, 0, len(msg.ToolCalls)) for _, tc := range msg.ToolCalls { var args map[string]any if tc.Function.Arguments != "" { _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) } if args == nil { args = map[string]any{} } calls = append(calls, frontendTC{Name: tc.Function.Name, Args: args}) } return msg.Content, calls, nil } // aiToolDefinitions — v2.12.11: schema for every AI-callable tool. Sent on // every chat turn so the model knows what's available; models without tool // support silently ignore the field. func aiToolDefinitions() []map[string]any { return []map[string]any{ { "type": "function", "function": map[string]any{ "name": "create_proxy_host", "description": "Create a new proxy host in CaddyUI. Use this when the user asks to actually create / set up / add a proxy host (not just explain or show a config snippet). The user will see a confirmation card with the arguments before this runs.", "parameters": map[string]any{ "type": "object", "properties": map[string]any{ "domains": map[string]any{"type": "string", "description": "Comma-separated list of domain names. Wildcards allowed (e.g. '*.example.com,example.com')."}, "forward_scheme": map[string]any{"type": "string", "enum": []string{"http", "https"}, "description": "Scheme used when proxying to the upstream. Default 'http'."}, "forward_host": map[string]any{"type": "string", "description": "Upstream hostname or IP (docker service name like 'adguardhome', or an IP)."}, "forward_port": map[string]any{"type": "integer", "description": "Upstream TCP port, 1–65535."}, "ssl_enabled": map[string]any{"type": "boolean", "description": "Enable Caddy automatic HTTPS via Let's Encrypt. Default true."}, "ssl_forced": map[string]any{"type": "boolean", "description": "Redirect plain HTTP requests to HTTPS. Default true."}, }, "required": []string{"domains", "forward_host", "forward_port"}, }, }, }, { "type": "function", "function": map[string]any{ "name": "create_redirection", "description": "Create a new redirection (HTTP 301/302/307/308) in CaddyUI. Use this when the user asks to redirect one hostname to another.", "parameters": map[string]any{ "type": "object", "properties": map[string]any{ "domains": map[string]any{"type": "string", "description": "Comma-separated source domains (the old hostnames being redirected away)."}, "forward_scheme": map[string]any{"type": "string", "enum": []string{"auto", "http", "https"}, "description": "Scheme for the redirect target. 'auto' (default) preserves the request scheme."}, "forward_domain": map[string]any{"type": "string", "description": "Target hostname (where the user gets redirected to)."}, "forward_http_code": map[string]any{"type": "integer", "enum": []int{301, 302, 307, 308}, "description": "Redirect status code. 301 = permanent (default), 302 = temporary, 307/308 preserve method."}, "preserve_path": map[string]any{"type": "boolean", "description": "Keep the path+query from the request when redirecting. Default true."}, }, "required": []string{"domains", "forward_domain"}, }, }, }, } } // apiAIExecTool — v2.12.11: executes a tool call the AI proposed. Called // from the frontend after the user confirms via the chat-bubble card. All // resources are created with admin / global ownership (or current user's // ownership for non-admins). Every successful exec writes an // `ai_tool_call` activity-log row so admins can audit what the AI did. func (s *Server) apiAIExecTool(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") enabled, _ := models.GetSetting(s.DB, settingAIEnabled) if enabled != "1" { _ = json.NewEncoder(w).Encode(map[string]string{"error": "AI assist is disabled."}) return } cu := s.currentUser(r) if cu == nil { w.WriteHeader(http.StatusUnauthorized) _ = json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) return } var body struct { Name string `json:"name"` Args map[string]any `json:"args"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { _ = json.NewEncoder(w).Encode(map[string]string{"error": "could not parse JSON body"}) return } sid := s.currentServerID(r) var ownerID int64 if cu.Role != models.RoleAdmin { ownerID = cu.ID } switch body.Name { case "create_proxy_host": ph := &models.ProxyHost{ Domains: toolStr(body.Args, "domains"), ForwardScheme: toolStrDefault(body.Args, "forward_scheme", "http"), ForwardHost: toolStr(body.Args, "forward_host"), ForwardPort: toolInt(body.Args, "forward_port"), SSLEnabled: toolBoolDefault(body.Args, "ssl_enabled", true), SSLForced: toolBoolDefault(body.Args, "ssl_forced", true), Enabled: true, } if ph.Domains == "" || ph.ForwardHost == "" || ph.ForwardPort == 0 { _ = json.NewEncoder(w).Encode(map[string]string{"error": "missing required argument (domains, forward_host, forward_port)"}) return } // GHSA-5h8j-xxm3-7ggr: this path skipped the same non-admin upstream // SSRF guard every other proxy-host creation path applies (see // validateProxyUpstreamsForUser / upstream_guard.go), so a non-admin // account could use the AI tool to point a proxy at the Caddy admin // API, loopback, or link-local/metadata addresses. if msg := s.validateProxyUpstreamsForUser(cu, ph); msg != "" { _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) return } // Domain conflict guard — same as the form path. if conflict, err := models.DomainsConflict(s.DB, sid, ph.DomainList(), 0, 0); err != nil { _ = json.NewEncoder(w).Encode(map[string]string{"error": "validate domains: " + err.Error()}) return } else if conflict != "" { _ = json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("domain %q is already used by another proxy or redirect on this server", conflict)}) return } id, err := models.CreateProxyHost(s.DB, sid, ownerID, ph) if err != nil { _ = json.NewEncoder(w).Encode(map[string]string{"error": "create proxy host: " + err.Error()}) return } _ = models.LogActivity(s.DB, sid, cu.Email, "ai_tool_call", fmt.Sprintf("proxy:%d", id), "create_proxy_host: "+ph.Domains, true) s.trySyncCaddy(sid, ph.CertificateID != 0) _ = json.NewEncoder(w).Encode(map[string]any{ "success": true, "summary": fmt.Sprintf("✓ Created proxy host #%d — %s → %s:%d", id, ph.Domains, ph.ForwardHost, ph.ForwardPort), "url": fmt.Sprintf("/proxy-hosts/%d/edit", id), }) case "create_redirection": code := toolIntDefault(body.Args, "forward_http_code", 301) if code != 301 && code != 302 && code != 307 && code != 308 { code = 301 } rh := &models.RedirectionHost{ Domains: toolStr(body.Args, "domains"), ForwardScheme: toolStrDefault(body.Args, "forward_scheme", "auto"), ForwardDomain: toolStr(body.Args, "forward_domain"), ForwardHTTPCode: code, PreservePath: toolBoolDefault(body.Args, "preserve_path", true), SSLEnabled: true, SSLForced: true, Enabled: true, } if rh.Domains == "" || rh.ForwardDomain == "" { _ = json.NewEncoder(w).Encode(map[string]string{"error": "missing required argument (domains, forward_domain)"}) return } if conflict, err := models.DomainsConflict(s.DB, sid, rh.DomainList(), 0, 0); err != nil { _ = json.NewEncoder(w).Encode(map[string]string{"error": "validate domains: " + err.Error()}) return } else if conflict != "" { _ = json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("domain %q is already used by another proxy or redirect on this server", conflict)}) return } id, err := models.CreateRedirectionHost(s.DB, sid, ownerID, rh) if err != nil { _ = json.NewEncoder(w).Encode(map[string]string{"error": "create redirection: " + err.Error()}) return } _ = models.LogActivity(s.DB, sid, cu.Email, "ai_tool_call", fmt.Sprintf("redirect:%d", id), "create_redirection: "+rh.Domains+" → "+rh.ForwardDomain, true) s.trySyncCaddy(sid, false) _ = json.NewEncoder(w).Encode(map[string]any{ "success": true, "summary": fmt.Sprintf("✓ Created redirection #%d — %s → %s (%d)", id, rh.Domains, rh.ForwardDomain, code), "url": fmt.Sprintf("/redirection-hosts/%d/edit", id), }) default: _ = json.NewEncoder(w).Encode(map[string]string{"error": "unknown tool: " + body.Name}) } } // toolStr / toolInt / toolBool — v2.12.11: tiny helpers that pull strongly- // typed values out of the model-supplied tool argument map. Models can be // loose with types (numbers as strings, booleans as strings, etc.), so // these tolerate the common drift cases. func toolStr(args map[string]any, key string) string { if args == nil { return "" } if v, ok := args[key]; ok { switch s := v.(type) { case string: return strings.TrimSpace(s) case float64: return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%f", s), "0"), ".") case bool: if s { return "true" } return "false" } } return "" } func toolStrDefault(args map[string]any, key, def string) string { if v := toolStr(args, key); v != "" { return v } return def } func toolInt(args map[string]any, key string) int { if args == nil { return 0 } if v, ok := args[key]; ok { switch n := v.(type) { case float64: return int(n) case int: return n case string: i, _ := strconv.Atoi(strings.TrimSpace(n)) return i } } return 0 } func toolIntDefault(args map[string]any, key string, def int) int { if v := toolInt(args, key); v != 0 { return v } return def } func toolBoolDefault(args map[string]any, key string, def bool) bool { if args == nil { return def } if v, ok := args[key]; ok { switch b := v.(type) { case bool: return b case string: s := strings.ToLower(strings.TrimSpace(b)) if s == "true" || s == "1" || s == "yes" { return true } if s == "false" || s == "0" || s == "no" { return false } } } return def } // apiPreviewProxyHost — live previews of both the readable Caddyfile excerpt // and generated route JSON for the in-progress proxy-host edit form. Reuses // the same form parser as createProxyHost so unsaved changes are represented // in both views. // // v2.11.17: pads required fields with visible placeholders so a partly- // filled form still renders a representative preview. The previous // version leaked the raw `strconv.Atoi: parsing "": invalid syntax` // from the strict parser into the user's face the moment they expanded // the panel before filling in Forward port. func (s *Server) apiPreviewProxyHost(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") // v2.11.18: the frontend sends FormData (multipart). r.ParseForm only // handles application/x-www-form-urlencoded bodies; for multipart we // also need ParseMultipartForm. Calling both means r.Form is populated // regardless of how the JS chose to encode the request. _ = r.ParseForm() _ = r.ParseMultipartForm(32 << 20) pad := func(key, fallback string) { if strings.TrimSpace(r.Form.Get(key)) == "" { r.Form.Set(key, fallback) if r.PostForm != nil { r.PostForm.Set(key, fallback) } } } pad("forward_port", "0") pad("forward_host", "(your-upstream)") pad("domains", "example.com") p, err := parseProxyHostForm(r) if err != nil { _ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) return } p.ExtraUpstreams = marshalExtraUpstreams(r) var previewHandlers []any if r.FormValue("basicauth_enabled") == "on" { p.BasicAuthEnabled = true users := previewBasicAuthUsers(r) usersJSON, _ := json.Marshal(users) p.BasicAuthUsers = string(usersJSON) if authHandler := buildBasicAuthPreviewHandler(users, p.BasicAuthRealm); authHandler != nil { previewHandlers = append(previewHandlers, authHandler) } } advancedError := "" var previewOverrides map[string]any if strings.TrimSpace(p.AdvancedConfig) != "" { if validationError := validateProxyAdvancedDirectives(p.AdvancedConfig); validationError != "" { advancedError = validationError } else { caddyClient := s.Caddy if s.DB != nil { caddyClient = s.caddyForRequest(r) } if caddyClient == nil { advancedError = "Caddy adapter is unavailable" } else if handlers, overrides, adaptErr := s.adaptProxyAdvancedWithClient(caddyClient, *p); adaptErr != nil { advancedError = adaptErr.Error() } else { previewHandlers = append(previewHandlers, handlers...) previewOverrides = overrides } } } previewProxy := *p if previewProxy.APIKeyValue != "" { previewProxy.APIKeyValue = previewRedacted } if previewProxy.LBCookieSecret != "" { previewProxy.LBCookieSecret = previewRedacted } if previewProxy.HTTPBasicAuthUpstream != "" { previewProxy.HTTPBasicAuthUpstream = "redacted:redacted" } if previewProxy.HealthCheckBasicAuth != "" { previewProxy.HealthCheckBasicAuth = "redacted:redacted" } previewProxy.ForwardProxyURL = redactPreviewURL(previewProxy.ForwardProxyURL) previewProxy.ForwardAuthURL = redactPreviewURL(previewProxy.ForwardAuthURL) route := caddy.BuildProxyRoute(previewProxy, previewHandlers) caddy.MergeReverseProxyOverrides(route, previewOverrides) route = redactProxyRoutePreview(route).(map[string]any) caddyfile := caddy.RenderProxyHostCaddyfileWithCertificate(*p, s.certificateForCaddyfile(p.CertificateID)) enc := json.NewEncoder(w) enc.SetIndent("", " ") response := map[string]any{"route": route, "caddyfile": caddyfile} if advancedError != "" { response["advanced_error"] = advancedError } _ = enc.Encode(response) } // globalSearch — v2.11.5: ⌘K / Ctrl+K command palette. Returns a flat list of // every proxy host, redirection, raw route, and certificate visible to the // current user on the active server. The frontend caches the result and // filters client-side; one fetch per palette open (with a 60-second // freshness window). func (s *Server) globalSearch(w http.ResponseWriter, r *http.Request) { cu := s.currentUser(r) if cu == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } isAdmin := cu.Role == models.RoleAdmin viewerID := cu.ID peers := s.groupPeerIDs(r) sid := s.currentServerID(r) type item struct { Type string `json:"type"` ID int64 `json:"id"` Label string `json:"label"` Sub string `json:"sub"` URL string `json:"url"` } items := make([]item, 0, 256) if hosts, err := models.ListProxyHosts(s.DB, sid, viewerID, isAdmin, peers); err == nil { for _, h := range hosts { items = append(items, item{ Type: "proxy", ID: h.ID, Label: h.Domains, Sub: fmt.Sprintf("→ %s:%d", h.ForwardHost, h.ForwardPort), URL: fmt.Sprintf("/proxy-hosts/%d/edit", h.ID), }) } } if redirs, err := models.ListRedirectionHosts(s.DB, sid, viewerID, isAdmin, peers); err == nil { for _, h := range redirs { items = append(items, item{ Type: "redirect", ID: h.ID, Label: h.Domains, Sub: fmt.Sprintf("→ %s://%s (%d)", h.ForwardScheme, h.ForwardDomain, h.ForwardHTTPCode), URL: fmt.Sprintf("/redirection-hosts/%d/edit", h.ID), }) } } if raws, err := models.ListRawRoutes(s.DB, sid, viewerID, isAdmin, peers); err == nil { for _, rr := range raws { label := rr.Label if label == "" { label = fmt.Sprintf("Advanced route #%d", rr.ID) } items = append(items, item{ Type: "raw", ID: rr.ID, Label: label, Sub: "Advanced route", URL: fmt.Sprintf("/raw-routes/%d/edit", rr.ID), }) } } if certs, err := models.ListCertificatesForUser(s.DB, sid, viewerID, isAdmin, peers); err == nil { for _, c := range certs { items = append(items, item{ Type: "cert", ID: c.ID, Label: c.Name, Sub: c.Domains, URL: fmt.Sprintf("/certificates/%d/edit", c.ID), }) } } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"results": items}) } func (s *Server) apiUpstreamHealth(w http.ResponseWriter, r *http.Request) { cu := s.currentUser(r) isAdmin := cu != nil && cu.Role == models.RoleAdmin var viewerID int64 if cu != nil { viewerID = cu.ID } sid := s.currentServerID(r) hosts, err := models.ListProxyHostSummaries(s.DB, sid, viewerID, isAdmin, s.groupPeerIDs(r)) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Ask Caddy's admin API for its own upstream health data. // This is the authoritative source — Caddy can reach Docker-internal hosts // by name (e.g. "gitlab", "snipeit-app") that CaddyUI cannot resolve. // Falls back to direct probe only for upstreams not yet in Caddy's config. caddyUpstreams := map[string]caddyUpstreamInfo{} if srv, err := models.GetCaddyServer(s.DB, sid); err == nil { caddyUpstreams = fetchCaddyUpstreams(srv.AdminURL) } results := make([]upstreamHealthResult, len(hosts)) var mu sync.Mutex var wg sync.WaitGroup // v2.57.1: never follow redirects on the direct upstream probe. A tenant's // public upstream could answer 302 -> http://127.0.0.1:2019/ or the cloud // metadata address and CaddyUI would fetch it, defeating the save-time // upstream guard. A redirect is a healthy answer; the target is irrelevant. client := &http.Client{ Timeout: 3 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, } for i, h := range hosts { results[i] = upstreamHealthResult{ID: h.ID, Domains: h.Domains} if !h.Enabled { results[i].Status = "disabled" continue } // v2.28.0 (issue #39): monitoring switched off for this host. Reading // Caddy's own upstream map would be harmless, but reporting a verdict // from it would contradict the UI's "monitoring off" state, and the // fallback branch below would emit exactly the probe traffic the // operator asked us to stop. Skip the host entirely. if h.MonitoringDisabled() { results[i].Status = "off" results[i].Error = "monitoring disabled for this host" continue } // Check Caddy's upstream map first (host:port key). key := fmt.Sprintf("%s:%d", h.ForwardHost, h.ForwardPort) if info, ok := caddyUpstreams[key]; ok { if info.Fails > 0 { results[i].Status = "error" results[i].Error = fmt.Sprintf("%d failed health check(s)", info.Fails) } else { results[i].Status = "ok" } continue } // Not in Caddy's upstream list yet (newly added / not yet synced, // or using an unmanaged handler the /reverse_proxy/upstreams API // doesn't expose). For Docker-internal backends (no dots in the // hostname — e.g. "status-server", "snipeit-app") a direct probe // from the caddyui container will fail with "no such host" since // caddyui usually isn't on the target's Docker network. Don't flag // that as error — mark it "unknown" (grey dot) so users don't see // a red "down" badge on a backend that's actually working. For // dotted hostnames (e.g. "api.example.com") we still try the probe. if isInternalHostname(h.ForwardHost) { results[i].Status = "unknown" results[i].Error = "caddyui cannot resolve " + h.ForwardHost + " — check via Caddy admin" continue } // Fall back to a direct probe for public / dotted hostnames. wg.Add(1) go func(idx int, h models.ProxyHost) { defer wg.Done() url := fmt.Sprintf("%s://%s:%d/", h.ForwardScheme, h.ForwardHost, h.ForwardPort) start := time.Now() resp, err2 := client.Head(url) if err2 != nil { resp, err2 = client.Get(url) } latency := time.Since(start).Milliseconds() if resp != nil { _ = resp.Body.Close() } mu.Lock() defer mu.Unlock() if err2 != nil { // DNS errors → "unknown" not "error": caddyui's network // namespace may legitimately not be able to resolve the // name even though Caddy can. if isDNSError(err2) { results[idx].Status = "unknown" results[idx].Error = err2.Error() } else { results[idx].Status = "error" results[idx].Error = err2.Error() } } else { results[idx].Status = "ok" results[idx].LatencyMS = latency } }(i, h) } wg.Wait() // Layer in cached app-response health (HTTPS GET / from the app-health // poller). Read under the RW lock; the poller writes on its own cadence // so this is a cheap map lookup per host. Hosts we haven't polled yet // (e.g. first ~60s after boot, or just-created hosts) simply leave // AppStatus empty — the UI renders "checking…" in that case. s.appHealthMu.RLock() for i := range results { if e, ok := s.appHealth[results[i].ID]; ok { results[i].AppStatus = e.Status results[i].AppCode = e.Code results[i].AppLatencyMS = e.LatencyMS results[i].AppError = e.Error } } s.appHealthMu.RUnlock() w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(results) } // caddyUpstreamInfo holds health data from Caddy's /reverse_proxy/upstreams API. type caddyUpstreamInfo struct { Address string `json:"address"` NumRequests int `json:"num_requests"` Fails int `json:"fails"` } // isInternalHostname returns true when the forward host looks like a Docker // service name or short intranet hostname — i.e. no dots, not an IP literal, // not "localhost". In that case caddyui (in its own container) almost // certainly cannot resolve it, but Caddy on the target network can. Use this // to downgrade health-probe results from "error" to "unknown" so the UI // doesn't flag working backends as down. func isInternalHostname(host string) bool { if host == "" || host == "localhost" { return false } if strings.Contains(host, ".") { return false } // IPv6 without brackets or with scope? Anything with a colon is unusual // in ForwardHost (we store port separately) — treat as external. if strings.Contains(host, ":") { return false } return true } // isDNSError returns true when err is a DNS resolution failure (as opposed // to a connection refused / timeout / TLS error). DNS failure from the // caddyui container doesn't imply the backend is down — Caddy on a // different Docker network may resolve it fine. func isDNSError(err error) bool { if err == nil { return false } s := err.Error() return strings.Contains(s, "no such host") || strings.Contains(s, "server misbehaving") || strings.Contains(s, "Temporary failure in name resolution") } // fetchCaddyUpstreams queries the Caddy admin API for current upstream health. // Returns a map keyed by "host:port" (matching ProxyHost.ForwardHost:ForwardPort). func fetchCaddyUpstreams(adminURL string) map[string]caddyUpstreamInfo { out := map[string]caddyUpstreamInfo{} client := &http.Client{Timeout: 3 * time.Second} resp, err := client.Get(adminURL + "/reverse_proxy/upstreams") if err != nil || resp.StatusCode != http.StatusOK { return out } defer resp.Body.Close() var list []caddyUpstreamInfo if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&list); err != nil { return out } for _, u := range list { out[u.Address] = u } return out } // apiCaddyUpstreams proxies Caddy's /reverse_proxy/upstreams endpoint, returning // raw upstream health data from Caddy's admin API. // Returns JSON: {"upstreams": [...], "error": "..."} func (s *Server) apiCaddyUpstreams(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() sid := s.currentServerID(r) srv, err := models.GetCaddyServer(s.DB, sid) w.Header().Set("Content-Type", "application/json") if err != nil { _ = json.NewEncoder(w).Encode(map[string]any{"upstreams": nil, "error": err.Error()}) return } cl := caddy.New(srv.AdminURL, srv.AdminUsername, srv.AdminPassword) upstreams, err := cl.GetUpstreamHealth(ctx) cu := s.currentUser(r) if err != nil { msg := err.Error() if !isAdminUser(cu) { msg = "could not reach Caddy" // the error text repeats the node's admin URL } _ = json.NewEncoder(w).Encode(map[string]any{"upstreams": nil, "error": msg}) return } // v2.57.1: Caddy reports EVERY upstream on the server. A non-admin sees only // the upstreams of the hosts they can see in the host list. if !isAdminUser(cu) && cu != nil { hosts, _ := models.ListProxyHosts(s.DB, sid, cu.ID, false, s.groupPeerIDs(r)) allowed := map[string]bool{} for _, h := range hosts { allowed[fmt.Sprintf("%s:%d", h.ForwardHost, h.ForwardPort)] = true for _, extra := range h.ExtraUpstreamList() { allowed[extra] = true } } visible := upstreams[:0:0] for _, u := range upstreams { if allowed[u.Address] { visible = append(visible, u) } } upstreams = visible } _ = json.NewEncoder(w).Encode(map[string]any{"upstreams": upstreams}) } // apiTestUpstream checks whether a given upstream host:port is reachable. // Accepts POST with form fields: host, port, scheme (http/https). // Returns JSON: {ok: bool, status: int, latency_ms: int, error: string}. func (s *Server) apiTestUpstream(w http.ResponseWriter, r *http.Request) { if s.currentUser(r) == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } // Accept both multipart/form-data (FormData from JS) and // application/x-www-form-urlencoded. r.FormValue auto-detects when // r.Form is still nil — calling r.ParseForm() first would short-circuit // the multipart path and leave the fields empty. host := strings.TrimSpace(r.FormValue("host")) port := strings.TrimSpace(r.FormValue("port")) scheme := strings.TrimSpace(r.FormValue("scheme")) if host == "" || port == "" { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": "host and port are required"}) return } if scheme != "https" { scheme = "http" } // v2.57.1: this endpoint makes CaddyUI itself connect to host:port and // reports status, latency and the error text, which makes it a port scanner // for whoever can call it. Non-admins get the same upstream rules as a proxy // host (no loopback / link-local / metadata / any Caddy admin API), and the // host and port must be plain — they are spliced into a URL. if !isAdminUser(s.currentUser(r)) { if strings.ContainsAny(host, "/?#@\\ ") || strings.ContainsAny(port, "/?#@\\ ") { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": "invalid host or port"}) return } if why := dialBlockedForTenant(net.JoinHostPort(host, port), s.adminHostSet()); why != "" { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": "Not allowed for a non-admin account: " + why}) return } } targetURL := fmt.Sprintf("%s://%s:%s/", scheme, host, port) client := &http.Client{ Timeout: 5 * time.Second, Transport: &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, }, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, } start := time.Now() resp, err := client.Get(targetURL) latencyMs := time.Since(start).Milliseconds() w.Header().Set("Content-Type", "application/json") if err != nil { // v2.9.212: surface actionable hints for the most common failure // classes so users don't have to interpret raw Go net errors. // DNS failures from inside the caddyui container are extremely // common when targeting LAN device hostnames (NetBIOS / mDNS / a // router's DHCP-resolved name) — the Caddy that will actually // proxy to it may resolve fine while caddyui's container can't. hint := "" msg := err.Error() switch { case isDNSError(err): hint = "DNS lookup failed inside the CaddyUI container. The hostname may resolve fine for the actual Caddy server but not from CaddyUI's DNS resolver. Try the upstream's IP address directly, add `--add-host=" + host + ":` to the CaddyUI container, or point the container at your LAN DNS with `--dns=`." case strings.Contains(msg, "x509") || strings.Contains(msg, "tls:") || strings.Contains(msg, "certificate"): hint = "TLS handshake failed even with cert verification disabled. The upstream may not be listening on " + port + " over TLS, or it speaks plain HTTP. Try scheme=http instead of https." case strings.Contains(msg, "connection refused"): hint = "TCP connection refused — the upstream isn't listening on " + host + ":" + port + ", or a firewall is blocking the CaddyUI container from reaching it." case strings.Contains(msg, "i/o timeout") || strings.Contains(msg, "deadline exceeded"): hint = "Request timed out after 5s. Upstream may be unreachable from the CaddyUI container's network, or it's too slow to respond." } out := map[string]any{ "ok": false, "latency_ms": latencyMs, "error": msg, } if hint != "" { out["hint"] = hint } json.NewEncoder(w).Encode(out) return } resp.Body.Close() json.NewEncoder(w).Encode(map[string]any{ "ok": resp.StatusCode < 500, "status": resp.StatusCode, "latency_ms": latencyMs, "error": "", }) } // apiValidateRawRoute checks whether a draft raw route is shaped correctly // before it's saved. Accepts either form field: // - caddyfile_src: a Caddyfile block — runs through Caddy's /adapt which // parses + validates Caddyfile syntax without applying anything. // - json_data: raw JSON — done client-side (well, server-side parse) // because Caddy's admin API has no JSON-only validate mode in v2.7+. // We surface JSON parse errors and structural-shape errors (must have // match/handle keys), but a route that's syntactically valid JSON yet // semantically wrong won't be caught here — that surfaces at sync time. // // v2.9.228 originally posted to /load?validate_only=true thinking that // query param was honoured. Caddy ignores it entirely, so the synthetic // config was applied to the running instance, polluting autosave.json // with a `_caddyui_validate` ghost server. v2.9.233 drops that path. func (s *Server) apiValidateRawRoute(w http.ResponseWriter, r *http.Request) { if s.currentUser(r) == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } w.Header().Set("Content-Type", "application/json") respond := func(ok bool, msg string) { out := map[string]any{"ok": ok} if msg != "" { out["error"] = msg } _ = json.NewEncoder(w).Encode(out) } cfSrc := strings.TrimSpace(r.FormValue("caddyfile_src")) jsonData := strings.TrimSpace(r.FormValue("json_data")) if cfSrc != "" { // Caddy's /adapt parses Caddyfile syntax and runs the adapter // pipeline — that catches the bulk of bugs (unknown directives, // missing args, malformed blocks). It does NOT apply the config // to Caddy's running state. _, _, err := s.adaptRawRouteCaddyfile(s.caddyForRequest(r), cfSrc) if err != nil { // Caddy adapts a client_auth block happily; it is CaddyUI that // turns it down, so don't put the words in Caddy's mouth. if errors.Is(err, errClientAuthInRawRoute) { respond(false, err.Error()) } else { respond(false, "Caddyfile rejected by Caddy: "+err.Error()) } return } // If Caddyfile is fine and JSON is empty (most common case), we're // done. If JSON is also filled, fall through to validate it too. if jsonData == "" { respond(true, "") return } } if jsonData == "" { respond(false, "Provide a Caddyfile block or JSON to validate.") return } // JSON-only validation: parse + structural check. var probe any if err := json.Unmarshal([]byte(jsonData), &probe); err != nil { respond(false, "Invalid JSON: "+err.Error()) return } checkRoute := func(m map[string]any) string { if _, ok := m["handle"]; !ok { return `route is missing "handle" array` } if h, ok := m["handle"].([]any); !ok || len(h) == 0 { return `"handle" must be a non-empty array` } // match is optional — a route with no matcher matches everything. return "" } switch v := probe.(type) { case map[string]any: if msg := checkRoute(v); msg != "" { respond(false, msg) return } case []any: if len(v) == 0 { respond(false, "Route JSON array is empty.") return } for i, e := range v { m, ok := e.(map[string]any) if !ok { respond(false, fmt.Sprintf("entry %d is not an object", i)) return } if msg := checkRoute(m); msg != "" { respond(false, fmt.Sprintf("entry %d: %s", i, msg)) return } } default: respond(false, "Route JSON must be an object or array of route objects.") return } respond(true, "") }