mirror of
https://github.com/Pacerino/CaddyProxyManager.git
synced 2026-08-04 03:05:30 -04:00
Introduce per-host plugin support: Caddy module discovery, module config endpoints, plugin schemas (basicauth, cloudflare) and the host plugins API. On the frontend, add the Plugins page, plugin/host plugin dialogs and schema-driven form fields.
194 lines
5.4 KiB
Go
194 lines
5.4 KiB
Go
package caddy
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Pacerino/CaddyProxyManager/internal/config"
|
|
"github.com/Pacerino/CaddyProxyManager/internal/database"
|
|
)
|
|
|
|
// APIProvider manages Caddy's configuration through its admin API by
|
|
// maintaining one JSON route per host.
|
|
type APIProvider struct {
|
|
baseURL string
|
|
serverName string
|
|
listen []string
|
|
client *http.Client
|
|
}
|
|
|
|
// NewAPIProvider builds an APIProvider from the global configuration.
|
|
func NewAPIProvider() *APIProvider {
|
|
return &APIProvider{
|
|
baseURL: strings.TrimRight(config.Configuration.Caddy.AdminURL, "/"),
|
|
serverName: config.Configuration.Caddy.ServerName,
|
|
listen: config.Configuration.Caddy.Listen,
|
|
client: &http.Client{Timeout: 15 * time.Second},
|
|
}
|
|
}
|
|
|
|
// WriteHost creates or updates the route for a host.
|
|
func (p *APIProvider) WriteHost(h database.Host) error {
|
|
if err := p.ensureServer(); err != nil {
|
|
return err
|
|
}
|
|
|
|
route := buildRoute(h)
|
|
id := hostRouteID(h.ID)
|
|
|
|
exists, err := p.routeExists(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if exists {
|
|
// Replace the existing route in place via its @id.
|
|
return p.do(http.MethodPatch, "/id/"+id, route, nil)
|
|
}
|
|
// Append a new route to the server's route list.
|
|
path := fmt.Sprintf("/config/apps/http/servers/%s/routes", p.serverName)
|
|
return p.do(http.MethodPost, path, route, nil)
|
|
}
|
|
|
|
// GetConfig returns the live Caddy configuration at the given path. Pass an
|
|
// empty path for the full config. The result is decoded into out, which is
|
|
// typically a *json.RawMessage or *map[string]any.
|
|
func (p *APIProvider) GetConfig(path string, out any) error {
|
|
full := "/config/" + strings.TrimPrefix(strings.TrimPrefix(path, "/config/"), "/")
|
|
status, data, err := p.request(http.MethodGet, full, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status < 200 || status >= 300 {
|
|
return fmt.Errorf("caddy admin GET %s returned %d: %s", full, status, string(data))
|
|
}
|
|
if out == nil || len(data) == 0 {
|
|
return nil
|
|
}
|
|
return json.Unmarshal(data, out)
|
|
}
|
|
|
|
// PatchConfig merges body into the config at path using the admin API. Caddy's
|
|
// PATCH replaces the value at the addressed path, so callers should send the
|
|
// full object they want present at that path.
|
|
func (p *APIProvider) PatchConfig(path string, body any) error {
|
|
full := "/config/" + strings.TrimPrefix(strings.TrimPrefix(path, "/config/"), "/")
|
|
return p.do(http.MethodPatch, full, body, nil)
|
|
}
|
|
|
|
// RemoveHost deletes the route for a host.
|
|
func (p *APIProvider) RemoveHost(hostID int) error {
|
|
id := hostRouteID(uint(hostID))
|
|
exists, err := p.routeExists(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !exists {
|
|
return nil
|
|
}
|
|
return p.do(http.MethodDelete, "/id/"+id, nil, nil)
|
|
}
|
|
|
|
// configExists reports whether the config at path is present. Caddy returns
|
|
// HTTP 200 with a literal "null" body for a valid-but-empty traversal path, so
|
|
// a successful status alone is not enough.
|
|
func (p *APIProvider) configExists(path string) (bool, error) {
|
|
status, body, err := p.request(http.MethodGet, path, nil)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if status < 200 || status >= 300 {
|
|
// Unknown/invalid path: treat as not present.
|
|
return false, nil
|
|
}
|
|
trimmed := strings.TrimSpace(string(body))
|
|
if trimmed == "" || trimmed == "null" {
|
|
return false, nil
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// routeExists reports whether a route with the given @id is present.
|
|
func (p *APIProvider) routeExists(id string) (bool, error) {
|
|
return p.configExists("/id/" + id)
|
|
}
|
|
|
|
// ensureServer makes sure the http app and target server exist so routes can
|
|
// be appended. It is a no-op when the server is already present.
|
|
func (p *APIProvider) ensureServer() error {
|
|
exists, err := p.configExists(fmt.Sprintf("/config/apps/http/servers/%s", p.serverName))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if exists {
|
|
return nil
|
|
}
|
|
|
|
// Bootstrap a minimal http app with an empty server.
|
|
server := map[string]any{
|
|
"listen": p.listen,
|
|
"routes": []any{},
|
|
}
|
|
httpApp := map[string]any{
|
|
"servers": map[string]any{p.serverName: server},
|
|
}
|
|
// PATCH the http app so an existing (empty) app is merged rather than
|
|
// rejected with a 409 conflict like PUT would be.
|
|
return p.do(http.MethodPatch, "/config/apps/http", httpApp, nil)
|
|
}
|
|
|
|
// do sends a request and treats any non-2xx status as an error.
|
|
func (p *APIProvider) do(method, path string, body any, out any) error {
|
|
status, data, err := p.request(method, path, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status < 200 || status >= 300 {
|
|
return fmt.Errorf("caddy admin %s %s returned %d: %s", method, path, status, string(data))
|
|
}
|
|
if out != nil && len(data) > 0 {
|
|
return json.Unmarshal(data, out)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// request performs an admin API call and returns the status code and body.
|
|
func (p *APIProvider) request(method, path string, body any) (int, []byte, error) {
|
|
var reader io.Reader
|
|
if body != nil {
|
|
buf, err := json.Marshal(body)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
reader = bytes.NewReader(buf)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, p.baseURL+path, reader)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
|
|
resp, err := p.client.Do(req)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return resp.StatusCode, nil, err
|
|
}
|
|
return resp.StatusCode, data, nil
|
|
}
|