internal/server/upstream_health.go
Rama main · réplica de lectura del repositorio original.
// 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 <details> 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 <details> 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 + ":<ip>` to the CaddyUI container, or point the container at your LAN DNS with `--dns=<router-ip>`."
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, "")
}