mirror of
https://github.com/caddyserver/caddy.git
synced 2026-10-31 08:21:39 -04:00
* docs: add Caddyfile lexer/parser/formatter unification design spec Design for unifying the Caddyfile lexer/parser and the standalone formatter by making the token pipeline drive formatting: raw-source-span capture, comment tokens, separator-kind metadata, follow-imports and (default-off) braced-wrap modes. Includes adversarial-review hardening and empirically-found intentional improvements over the legacy formatter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: add phased implementation plan for Caddyfile formatter unification Task-by-task TDD plan: Phase 1 (token model + formatter rewrite + fuzz invariants + legacy oracle), Phase 2 (follow-imports mode + caddy fmt --imports), Phase 3 (default-off braced-wrap). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: vendor legacy Caddyfile formatter as differential oracle Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * caddyfile: add format-mode Token fields and accessors Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * caddyfile: add Lex and LexOptions; Tokenize delegates to Lex Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * caddyfile: capture verbatim raw source spans in format mode Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * caddyfile: emit comment tokens in format mode Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * caddyfile: record token separator kind in format mode Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * caddyfile: split structural braces in format mode Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * caddyfile: rewrite Format as a token-based renderer (core layout) Replace the rune-by-rune state machine with a renderer that consumes the format-mode token stream (Lex with Comments+Raw). Adds FormatWithOptions and FormatOptions{WrapUnbracedSite}; Format keeps its signature. Comment placement, continuations, empty-block expansion, and the import/}-glue rules land in Tasks 9-11; the six dependent TestFormatter cases are temporarily skipped with annotations. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * caddyfile: token renderer comments (I1/I3) and continuations (D1) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * caddyfile: token renderer empty blocks (I2) and post-} break (I4) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * caddyfile: import-brace exception, drop nesting cap, drop < quirk Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * caddyfile: fix formatter idempotency/semantic bugs, retire legacy oracle Fix two fuzz-found bugs plus several related idempotency issues in the token-based Caddyfile formatter, and retire the legacy byte-scanner formatter used as a test oracle. Bug B (formatter.go): a stray "}" at nesting 0 was glued to an unquoted predecessor because precededBySpace is unreliable after an unquoted token. Only suppress the separating space for an inline close brace when the previous token is quoted (same guard as comment gluing), so "0 }" no longer merges into "0}". Bug A (lexer.go): an empty-bodied heredoc hitting EOF with no closing marker was silently dropped, breaking Format idempotency. Surface it as an "incomplete heredoc" error ONLY in format mode (opts.Comments || opts.Raw); Tokenize/Parse keep the exact prior silent-drop behavior. Format then falls back to preserving the trimmed input. Additional format-mode-only fixes surfaced by property fuzzing: correct line-break accounting for empty-bodied heredocs and continuation-framed quoted tokens; normalize the raw slice / clear continuation on split and standalone structural braces; guard the address/comment/brace fold so a next-line "{" is not folded onto a comment trailing an opening brace; and a fallback + idempotency backstop in FormatWithOptions for degenerate tokens (ambiguous heredoc openers, unterminated/escaped quotes, dangling escapes, messy/leading line continuations). The parse path is unchanged. Property fuzz targets (formatter_test.go): keep FuzzFormatIdempotent, FuzzFormatNoPanic, and a redesigned FuzzFormatSemanticPreserve that asserts format-mode token-text preservation (robust to the parser's lenient grouping of ambiguous brace layouts). Add regression tests TestFormatStrayCloseBraceKeepsTokens and TestFormatIncompleteHeredocIdempotent. Remove FuzzFormatParity, containsParityExclusion, maxNesting and delete formatter_legacy_test.go (legacyFormat oracle + TestLegacyOracleMatchesCurrent). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * caddyfile: seed corpus and FormattingDifference stability tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * caddyfile: extract pure import-glob resolution helper Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * caddyfile: import discovery for follow-imports formatting Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * caddyfile: FormatImports formats root plus imported files Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * cmd: add 'caddy fmt --imports' to format imported files Adds a --imports flag to 'caddy fmt' that calls caddyfile.FormatImports to format the root Caddyfile and all files it references via import directives. Rejects --imports with stdin (no source directory to resolve against). With --overwrite each file is written back; otherwise each file's output is printed to stdout with a '# <path>' header, with optional per-file diff via --diff. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * caddyfile: detect single unbraced site block Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * caddyfile: implement default-off unbraced->braced site wrapping Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * caddyfile: make WrapUnbracedSite decision on normalized stream (idempotency fix) FormatWithOptions wrapped before formatting, deciding on the raw token stream. isSingleUnbracedSite uses source-line gaps, but formatTokens removes blank lines and folds dangling braces, so a shape read as "not a single site" on pass 1 could read as a single unbraced site on pass 2 and get wrapped, breaking Format(Format(x)) == Format(x). Decide the wrap on the already-normalized stream instead: normalize, re-lex, and wrap only if isSingleUnbracedSite holds there. tokens becomes the wrapped stream so the backstop still compares like-for-like; once wrapped the output is braced, so no double-wrap on re-runs. Format (zero options) still never wraps. Adds a regression reproducer and FuzzWrapUnbracedSiteIdempotent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * caddyfile: satisfy golangci-lint (rangeint + De Morgan) Modernize three index loops to range-over-int and simplify a negated boolean per staticcheck QF1001. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * caddyfile: modernize remaining index loops (rangeint) Convert the remaining for-i index loops to range-over-int per golangci-lint modernize. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * caddyfile: fix formatter and import correctness issues (#7881) * caddyfile: fix formatter and import correctness issues * noot * noot * caddyfile: document parser-driven import discovery * test: make formatter import tests portable (#7882) * caddyfile: propagate import observer into named route blocks Named route blocks expand their imports through a separate parser built in expandImportsInBlock, which copied only definedSnippets and importGraph. The importObserver hook was left nil, so FormatImports never saw files imported from inside a &(name) { ... } block and "caddy fmt --imports" silently skipped them. Carry the observer into that parser and cover it with a discovery test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * caddyfile: detect end-of-input-swallowing tokens from token shape hasUnformattableToken documented an unterminated quote/backtick as a shape that cannot be rendered idempotently, but never implemented the check. The condition was only caught dynamically by trailingNewlineChangesTokens, which returns early for input that already ends in a newline. That early return is wrong for a token that swallowed the newline: once Format's mandatory trailing newline sits inside an unterminated quote, appending another one no longer changes the stream, so the second pass rendered what the first pass had preserved verbatim. Format(`"""`) => `"""` (fallback, preserved) Format(`"""` + newline) => `"" "` (rendered, space inserted) Decide it from the token instead: a non-quoted token whose verbatim source ends in a newline swallowed it, since a newline otherwise terminates a token. This also covers an escaped quote ("\"") running to end-of-input, which has the same shape. A lone "\r" is kept in raw but does not terminate a token, so it is deliberately excluded; heredoc tokens carry wasQuoted == '<'. Master's rune-based formatter was idempotent on these inputs, so this restores parity. Found by FuzzFormatIdempotent; the inputs are added as portable seeds and table cases rather than testdata/fuzz files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * caddyfile: stop Format from mutating its input buffer The three fallback paths returned append(bytes.TrimSpace(input), '\n'). TrimSpace returns a subslice sharing the caller's backing array, so the append wrote the newline into the caller's buffer. os.ReadFile returns a slice with one byte of spare capacity, which is exactly what the append needs, so this reached real callers: $ printf 'foo "unterminated \n\n' > Caddyfile $ caddy fmt --diff foo "unterminated <- reported unchanged; its trailing - whitespace was in fact stripped cmd/commandfuncs.go compares input against output after calling Format, and by then input had been overwritten to match. The same aliasing corrupted FuzzFormatIdempotent, which reported failures whose recorded inputs did not reproduce. Copy into a fresh buffer instead, and assert both non-mutation and non-aliasing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * caddyfile: preserve repeated import expansions during formatting Deduplicate formatted files without skipping imports with different arguments, block substitutions, or lexical aliases. Track active physical-file ancestry to prevent symlink cycles, including snippet and block expansion contexts. Add regression coverage and align the upstream glued-brace expectation with the token-based formatter's literal-token behavior. Validation: go test -race -short ./..., go build ./..., and golangci-lint run --timeout 10m all pass. AI assistance: OpenAI Codex (GPT-6) assisted with implementation and testing. * docs: remove formatter implementation plan and design spec * caddyfile: require whitespace before line continuations Preserve attached trailing backslashes as literal text, including Windows root paths. Require a space or tab before an unquoted continuation backslash, and preserve significant leading whitespace in formatter fallback output. Add lexer, formatter, CRLF, adapter, and fuzz regression coverage. Validated with race tests, build, lint, formatter fuzzing, and the 165-example forum corpus. AI assistance: OpenAI Codex (GPT-6) assisted with implementation, regression tests, and validation. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: a <a@tuxpa.in>
1117 lines
34 KiB
Go
1117 lines
34 KiB
Go
// Copyright 2015 Matthew Holt and The Caddy Authors
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package caddycmd
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"maps"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"runtime/debug"
|
|
"strings"
|
|
|
|
"github.com/aryann/difflib"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/caddyserver/caddy/v2"
|
|
"github.com/caddyserver/caddy/v2/caddyconfig"
|
|
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
|
|
"github.com/caddyserver/caddy/v2/internal"
|
|
)
|
|
|
|
func cmdStart(fl Flags) (int, error) {
|
|
configFlag := fl.String("config")
|
|
configAdapterFlag := fl.String("adapter")
|
|
pidfileFlag := fl.String("pidfile")
|
|
watchFlag := fl.Bool("watch")
|
|
|
|
var err error
|
|
var envfileFlag []string
|
|
envfileFlag, err = fl.GetStringSlice("envfile")
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup,
|
|
fmt.Errorf("reading envfile flag: %v", err)
|
|
}
|
|
|
|
// open a listener to which the child process will connect when
|
|
// it is ready to confirm that it has successfully started
|
|
ln, err := listenTCPForPingback(net.Listen)
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup,
|
|
fmt.Errorf("opening listener for success confirmation: %v", err)
|
|
}
|
|
defer ln.Close()
|
|
|
|
// craft the command with a pingback address and with a
|
|
// pipe for its stdin, so we can tell it our confirmation
|
|
// code that we expect so that some random port scan at
|
|
// the most unfortunate time won't fool us into thinking
|
|
// the child succeeded (i.e. the alternative is to just
|
|
// wait for any connection on our listener, but better to
|
|
// ensure it's the process we're expecting - we can be
|
|
// sure by giving it some random bytes and having it echo
|
|
// them back to us)
|
|
cmd := exec.Command(os.Args[0], "run", "--pingback", ln.Addr().String()) //nolint:gosec // no command injection that I can determine...
|
|
// we should be able to run caddy in relative paths
|
|
if errors.Is(cmd.Err, exec.ErrDot) {
|
|
cmd.Err = nil
|
|
}
|
|
if configFlag != "" {
|
|
cmd.Args = append(cmd.Args, "--config", configFlag)
|
|
}
|
|
|
|
for _, envfile := range envfileFlag {
|
|
cmd.Args = append(cmd.Args, "--envfile", envfile)
|
|
}
|
|
if configAdapterFlag != "" {
|
|
cmd.Args = append(cmd.Args, "--adapter", configAdapterFlag)
|
|
}
|
|
if watchFlag {
|
|
cmd.Args = append(cmd.Args, "--watch")
|
|
}
|
|
if pidfileFlag != "" {
|
|
cmd.Args = append(cmd.Args, "--pidfile", pidfileFlag)
|
|
}
|
|
stdinPipe, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup,
|
|
fmt.Errorf("creating stdin pipe: %v", err)
|
|
}
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
// generate the random bytes we'll send to the child process
|
|
expect := make([]byte, 32)
|
|
_, err = rand.Read(expect)
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup,
|
|
fmt.Errorf("generating random confirmation bytes: %v", err)
|
|
}
|
|
|
|
// begin writing the confirmation bytes to the child's
|
|
// stdin; use a goroutine since the child hasn't been
|
|
// started yet, and writing synchronously would result
|
|
// in a deadlock
|
|
go func() {
|
|
_, _ = stdinPipe.Write(expect)
|
|
stdinPipe.Close()
|
|
}()
|
|
|
|
// start the process
|
|
err = cmd.Start()
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup,
|
|
fmt.Errorf("starting caddy process: %v", err)
|
|
}
|
|
|
|
// there are two ways we know we're done: either
|
|
// the process will connect to our listener, or
|
|
// it will exit with an error
|
|
success, exit := make(chan struct{}), make(chan error)
|
|
|
|
// in one goroutine, we await the success of the child process
|
|
go func() {
|
|
for {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
if !errors.Is(err, net.ErrClosed) {
|
|
log.Println(err)
|
|
}
|
|
break
|
|
}
|
|
err = handlePingbackConn(conn, expect)
|
|
if err == nil {
|
|
close(success)
|
|
break
|
|
}
|
|
log.Println(err)
|
|
}
|
|
}()
|
|
|
|
// in another goroutine, we await the failure of the child process
|
|
go func() {
|
|
err := cmd.Wait() // don't send on this line! Wait blocks, but send starts before it unblocks
|
|
exit <- err // sending on separate line ensures select won't trigger until after Wait unblocks
|
|
}()
|
|
|
|
// when one of the goroutines unblocks, we're done and can exit
|
|
select {
|
|
case <-success:
|
|
fmt.Printf("Successfully started Caddy (pid=%d) - Caddy is running in the background\n", cmd.Process.Pid)
|
|
case err := <-exit:
|
|
return caddy.ExitCodeFailedStartup,
|
|
fmt.Errorf("caddy process exited with error: %v", err)
|
|
}
|
|
|
|
return caddy.ExitCodeSuccess, nil
|
|
}
|
|
|
|
type tcpListenFunc func(network, address string) (net.Listener, error)
|
|
|
|
func listenTCPForPingback(listen tcpListenFunc) (net.Listener, error) {
|
|
ln, ipv4Err := listen("tcp4", "127.0.0.1:0")
|
|
if ipv4Err == nil {
|
|
return ln, nil
|
|
}
|
|
|
|
ln, ipv6Err := listen("tcp6", "[::1]:0")
|
|
if ipv6Err == nil {
|
|
return ln, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("listen on 127.0.0.1:0: %v; listen on [::1]:0: %v", ipv4Err, ipv6Err)
|
|
}
|
|
|
|
func cmdRun(fl Flags) (int, error) {
|
|
caddy.TrapSignals()
|
|
|
|
// set up buffered logging for early startup
|
|
// so that we can hold onto logs until after
|
|
// the config is loaded (or fails to load)
|
|
// so that we can write the logs to the user's
|
|
// configured output. we must be sure to flush
|
|
// on any error before the config is loaded.
|
|
logger, defaultLogger, logBuffer := caddy.BufferedLog()
|
|
|
|
undoMaxProcs := setResourceLimits(logger)
|
|
defer undoMaxProcs()
|
|
// release the local reference to the undo function so it can be GC'd;
|
|
// the deferred call above has already captured the actual function value.
|
|
undoMaxProcs = nil //nolint:ineffassign,wastedassign
|
|
|
|
configFlag := fl.String("config")
|
|
configAdapterFlag := fl.String("adapter")
|
|
resumeFlag := fl.Bool("resume")
|
|
printEnvFlag := fl.Bool("environ")
|
|
watchFlag := fl.Bool("watch")
|
|
pidfileFlag := fl.String("pidfile")
|
|
pingbackFlag := fl.String("pingback")
|
|
|
|
// load all additional envs as soon as possible
|
|
err := handleEnvFileFlag(fl)
|
|
if err != nil {
|
|
logBuffer.FlushTo(defaultLogger)
|
|
return caddy.ExitCodeFailedStartup, err
|
|
}
|
|
|
|
// if we are supposed to print the environment, do that first
|
|
if printEnvFlag {
|
|
printEnvironment()
|
|
}
|
|
|
|
// load the config, depending on flags
|
|
var config []byte
|
|
if resumeFlag {
|
|
config, err = os.ReadFile(caddy.ConfigAutosavePath)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
// not a bad error; just can't resume if autosave file doesn't exist
|
|
logger.Info("no autosave file exists", zap.String("autosave_file", caddy.ConfigAutosavePath))
|
|
resumeFlag = false
|
|
} else if err != nil {
|
|
logBuffer.FlushTo(defaultLogger)
|
|
return caddy.ExitCodeFailedStartup, err
|
|
} else {
|
|
if configFlag == "" {
|
|
logger.Info("resuming from last configuration",
|
|
zap.String("autosave_file", caddy.ConfigAutosavePath))
|
|
} else {
|
|
// if they also specified a config file, user should be aware that we're not
|
|
// using it (doing so could lead to data/config loss by overwriting!)
|
|
logger.Warn("--config and --resume flags were used together; ignoring --config and resuming from last configuration",
|
|
zap.String("autosave_file", caddy.ConfigAutosavePath))
|
|
}
|
|
}
|
|
}
|
|
// we don't use 'else' here since this value might have been changed in 'if' block; i.e. not mutually exclusive
|
|
var configFile string
|
|
var adapterUsed string
|
|
if !resumeFlag {
|
|
config, configFile, adapterUsed, err = LoadConfig(configFlag, configAdapterFlag)
|
|
if err != nil {
|
|
logBuffer.FlushTo(defaultLogger)
|
|
return caddy.ExitCodeFailedStartup, err
|
|
}
|
|
}
|
|
|
|
// create pidfile now, in case loading config takes a while (issue #5477)
|
|
if pidfileFlag != "" {
|
|
err := caddy.PIDFile(pidfileFlag)
|
|
if err != nil {
|
|
logger.Error("unable to write PID file",
|
|
zap.String("pidfile", pidfileFlag),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// If we have a source config file (we're running via 'caddy run --config ...'),
|
|
// record it so SIGUSR1 can reload from the same file. Also provide a callback
|
|
// that knows how to load/adapt that source when requested by the main process.
|
|
if configFile != "" {
|
|
caddy.SetLastConfig(configFile, adapterUsed, func(file, adapter string) error {
|
|
cfg, _, _, err := LoadConfig(file, adapter)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return caddy.Load(cfg, true)
|
|
})
|
|
}
|
|
|
|
// run the initial config
|
|
err = caddy.Load(config, true)
|
|
if err != nil {
|
|
logBuffer.FlushTo(defaultLogger)
|
|
return caddy.ExitCodeFailedStartup, fmt.Errorf("loading initial config: %v", err)
|
|
}
|
|
// release the reference to the config so it can be GC'd
|
|
config = nil //nolint:ineffassign,wastedassign
|
|
|
|
// at this stage the config will have replaced the
|
|
// default logger to the configured one, so we can
|
|
// log normally, now that the config is running.
|
|
// also clear our ref to the buffer so it can get GC'd
|
|
logger = caddy.Log()
|
|
defaultLogger = nil //nolint:ineffassign,wastedassign
|
|
logBuffer = nil //nolint:wastedassign,ineffassign
|
|
logger.Info("serving initial configuration")
|
|
|
|
// if we are to report to another process the successful start
|
|
// of the server, do so now by echoing back contents of stdin
|
|
if pingbackFlag != "" {
|
|
confirmationBytes, err := io.ReadAll(os.Stdin)
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup,
|
|
fmt.Errorf("reading confirmation bytes from stdin: %v", err)
|
|
}
|
|
conn, err := net.Dial("tcp", pingbackFlag)
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup,
|
|
fmt.Errorf("dialing confirmation address: %v", err)
|
|
}
|
|
_, err = conn.Write(confirmationBytes)
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup,
|
|
fmt.Errorf("writing confirmation bytes to %s: %v", pingbackFlag, err)
|
|
}
|
|
// close (non-defer because we `select {}` below)
|
|
// and release references so they can be GC'd
|
|
conn.Close()
|
|
confirmationBytes = nil //nolint:ineffassign,wastedassign
|
|
conn = nil //nolint:wastedassign,ineffassign
|
|
}
|
|
|
|
// if enabled, reload config file automatically on changes
|
|
// (this better only be used in dev!)
|
|
if watchFlag {
|
|
go watchConfigFile(configFile, adapterUsed)
|
|
}
|
|
|
|
// warn if the environment does not provide enough information about the disk
|
|
hasXDG := os.Getenv("XDG_DATA_HOME") != "" &&
|
|
os.Getenv("XDG_CONFIG_HOME") != "" &&
|
|
os.Getenv("XDG_CACHE_HOME") != ""
|
|
switch runtime.GOOS {
|
|
case "windows":
|
|
if os.Getenv("HOME") == "" && os.Getenv("USERPROFILE") == "" && !hasXDG {
|
|
logger.Warn("neither HOME nor USERPROFILE environment variables are set - please fix; some assets might be stored in ./caddy")
|
|
}
|
|
case "plan9":
|
|
if os.Getenv("home") == "" && !hasXDG {
|
|
logger.Warn("$home environment variable is empty - please fix; some assets might be stored in ./caddy")
|
|
}
|
|
default:
|
|
if os.Getenv("HOME") == "" && !hasXDG {
|
|
logger.Warn("$HOME environment variable is empty - please fix; some assets might be stored in ./caddy")
|
|
}
|
|
}
|
|
|
|
// release the last local logger reference
|
|
logger = nil //nolint:wastedassign,ineffassign
|
|
|
|
select {}
|
|
}
|
|
|
|
func cmdStop(fl Flags) (int, error) {
|
|
addressFlag := fl.String("address")
|
|
configFlag := fl.String("config")
|
|
configAdapterFlag := fl.String("adapter")
|
|
|
|
adminAddr, err := DetermineAdminAPIAddress(addressFlag, nil, configFlag, configAdapterFlag)
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup, fmt.Errorf("couldn't determine admin API address: %v", err)
|
|
}
|
|
|
|
resp, err := AdminAPIRequest(adminAddr, http.MethodPost, "/stop", nil, nil)
|
|
if err != nil {
|
|
caddy.Log().Warn("failed using API to stop instance", zap.Error(err))
|
|
return caddy.ExitCodeFailedStartup, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
return caddy.ExitCodeSuccess, nil
|
|
}
|
|
|
|
func cmdReload(fl Flags) (int, error) {
|
|
configFlag := fl.String("config")
|
|
configAdapterFlag := fl.String("adapter")
|
|
addressFlag := fl.String("address")
|
|
forceFlag := fl.Bool("force")
|
|
|
|
// get the config in caddy's native format
|
|
config, configFile, adapterUsed, err := LoadConfig(configFlag, configAdapterFlag)
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup, err
|
|
}
|
|
if configFile == "" {
|
|
return caddy.ExitCodeFailedStartup, fmt.Errorf("no config file to load")
|
|
}
|
|
|
|
adminAddr, err := DetermineAdminAPIAddress(addressFlag, config, configFile, configAdapterFlag)
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup, fmt.Errorf("couldn't determine admin API address: %v", err)
|
|
}
|
|
|
|
// optionally force a config reload
|
|
headers := make(http.Header)
|
|
if forceFlag {
|
|
headers.Set("Cache-Control", "must-revalidate")
|
|
}
|
|
// Provide the source file/adapter to the running process so it can
|
|
// preserve its last-config knowledge if this reload came from the same source.
|
|
headers.Set("Caddy-Config-Source-File", configFile)
|
|
headers.Set("Caddy-Config-Source-Adapter", adapterUsed)
|
|
|
|
resp, err := AdminAPIRequest(adminAddr, http.MethodPost, "/load", headers, bytes.NewReader(config))
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup, fmt.Errorf("sending configuration to instance: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
return caddy.ExitCodeSuccess, nil
|
|
}
|
|
|
|
func cmdVersion(_ Flags) (int, error) {
|
|
_, full := caddy.Version()
|
|
fmt.Println(full)
|
|
return caddy.ExitCodeSuccess, nil
|
|
}
|
|
|
|
func cmdBuildInfo(_ Flags) (int, error) {
|
|
bi, ok := debug.ReadBuildInfo()
|
|
if !ok {
|
|
return caddy.ExitCodeFailedStartup, fmt.Errorf("no build information")
|
|
}
|
|
fmt.Println(bi)
|
|
return caddy.ExitCodeSuccess, nil
|
|
}
|
|
|
|
// jsonModuleInfo holds metadata about a Caddy module for JSON output.
|
|
type jsonModuleInfo struct {
|
|
ModuleName string `json:"module_name"`
|
|
ModuleType string `json:"module_type"`
|
|
Version string `json:"version,omitempty"`
|
|
PackageURL string `json:"package_url,omitempty"`
|
|
}
|
|
|
|
func cmdListModules(fl Flags) (int, error) {
|
|
packages := fl.Bool("packages")
|
|
versions := fl.Bool("versions")
|
|
skipStandard := fl.Bool("skip-standard")
|
|
jsonOutput := fl.Bool("json")
|
|
|
|
// Organize modules by whether they come with the standard distribution
|
|
standard, nonstandard, unknown, err := getModules()
|
|
if err != nil {
|
|
// If module info can't be fetched, just print the IDs and exit
|
|
for _, m := range caddy.Modules() {
|
|
fmt.Println(m)
|
|
}
|
|
return caddy.ExitCodeSuccess, nil
|
|
}
|
|
|
|
// Logic for JSON output
|
|
if jsonOutput {
|
|
output := []jsonModuleInfo{}
|
|
|
|
// addToOutput is a helper to convert internal module info to the JSON-serializable struct
|
|
addToOutput := func(list []moduleInfo, moduleType string) {
|
|
for _, mi := range list {
|
|
item := jsonModuleInfo{
|
|
ModuleName: mi.caddyModuleID,
|
|
ModuleType: moduleType, // Mapping the type here
|
|
}
|
|
if mi.goModule != nil {
|
|
item.Version = mi.goModule.Version
|
|
item.PackageURL = mi.goModule.Path
|
|
}
|
|
output = append(output, item)
|
|
}
|
|
}
|
|
|
|
// Pass the respective type for each category
|
|
if !skipStandard {
|
|
addToOutput(standard, "standard")
|
|
}
|
|
addToOutput(nonstandard, "non-standard")
|
|
addToOutput(unknown, "unknown")
|
|
|
|
jsonBytes, err := json.MarshalIndent(output, "", " ")
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedQuit, err
|
|
}
|
|
fmt.Println(string(jsonBytes))
|
|
return caddy.ExitCodeSuccess, nil
|
|
}
|
|
|
|
// Logic for Text output (Fallback)
|
|
printModuleInfo := func(mi moduleInfo) {
|
|
fmt.Print(mi.caddyModuleID)
|
|
if versions && mi.goModule != nil {
|
|
fmt.Print(" " + mi.goModule.Version)
|
|
}
|
|
if packages && mi.goModule != nil {
|
|
fmt.Print(" " + mi.goModule.Path)
|
|
if mi.goModule.Replace != nil {
|
|
fmt.Print(" => " + mi.goModule.Replace.Path)
|
|
}
|
|
}
|
|
if mi.err != nil {
|
|
fmt.Printf(" [%v]", mi.err)
|
|
}
|
|
fmt.Println()
|
|
}
|
|
|
|
// Standard modules (always shipped with Caddy)
|
|
if !skipStandard {
|
|
if len(standard) > 0 {
|
|
for _, mod := range standard {
|
|
printModuleInfo(mod)
|
|
}
|
|
}
|
|
fmt.Printf("\n Standard modules: %d\n", len(standard))
|
|
}
|
|
|
|
// Non-standard modules (third party plugins)
|
|
if len(nonstandard) > 0 {
|
|
if len(standard) > 0 && !skipStandard {
|
|
fmt.Println()
|
|
}
|
|
for _, mod := range nonstandard {
|
|
printModuleInfo(mod)
|
|
}
|
|
fmt.Printf("\n Non-standard modules: %d\n", len(nonstandard))
|
|
}
|
|
|
|
// Unknown modules (couldn't get Caddy module info)
|
|
if len(unknown) > 0 {
|
|
if (len(standard) > 0 && !skipStandard) || len(nonstandard) > 0 {
|
|
fmt.Println()
|
|
}
|
|
for _, mod := range unknown {
|
|
printModuleInfo(mod)
|
|
}
|
|
fmt.Printf("\n Unknown modules: %d\n", len(unknown))
|
|
}
|
|
|
|
return caddy.ExitCodeSuccess, nil
|
|
}
|
|
|
|
func cmdEnviron(fl Flags) (int, error) {
|
|
// load all additional envs as soon as possible
|
|
err := handleEnvFileFlag(fl)
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup, err
|
|
}
|
|
|
|
printEnvironment()
|
|
return caddy.ExitCodeSuccess, nil
|
|
}
|
|
|
|
func cmdAdaptConfig(fl Flags) (int, error) {
|
|
configFlag := fl.String("config")
|
|
adapterFlag := fl.String("adapter")
|
|
prettyFlag := fl.Bool("pretty")
|
|
validateFlag := fl.Bool("validate")
|
|
|
|
var err error
|
|
configFlag, err = configFileWithRespectToDefault(caddy.Log(), configFlag)
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup, err
|
|
}
|
|
if configFlag == "" {
|
|
return caddy.ExitCodeFailedStartup,
|
|
fmt.Errorf("input file required when there is no Caddyfile in current directory (use --config flag)")
|
|
}
|
|
|
|
// load all additional envs as soon as possible
|
|
err = handleEnvFileFlag(fl)
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup, err
|
|
}
|
|
|
|
if adapterFlag == "" {
|
|
return caddy.ExitCodeFailedStartup,
|
|
fmt.Errorf("adapter name is required (use --adapt flag or leave unspecified for default)")
|
|
}
|
|
|
|
cfgAdapter := caddyconfig.GetAdapter(adapterFlag)
|
|
if cfgAdapter == nil {
|
|
return caddy.ExitCodeFailedStartup,
|
|
fmt.Errorf("unrecognized config adapter: %s", adapterFlag)
|
|
}
|
|
|
|
var input []byte
|
|
// read from stdin if the file name is "-"
|
|
if configFlag == "-" {
|
|
input, err = io.ReadAll(os.Stdin)
|
|
} else {
|
|
input, err = os.ReadFile(configFlag)
|
|
}
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup,
|
|
fmt.Errorf("reading input file: %v", err)
|
|
}
|
|
|
|
opts := map[string]any{"filename": configFlag}
|
|
|
|
adaptedConfig, warnings, err := cfgAdapter.Adapt(input, opts)
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup, err
|
|
}
|
|
|
|
if prettyFlag {
|
|
var prettyBuf bytes.Buffer
|
|
err = json.Indent(&prettyBuf, adaptedConfig, "", "\t")
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup, err
|
|
}
|
|
adaptedConfig = prettyBuf.Bytes()
|
|
}
|
|
|
|
// print result to stdout
|
|
fmt.Println(string(adaptedConfig))
|
|
|
|
// print warnings to stderr
|
|
for _, warn := range warnings {
|
|
msg := warn.Message
|
|
if warn.Directive != "" {
|
|
msg = fmt.Sprintf("%s: %s", warn.Directive, warn.Message)
|
|
}
|
|
caddy.Log().Named(adapterFlag).Warn(msg,
|
|
zap.String("file", warn.File),
|
|
zap.Int("line", warn.Line))
|
|
}
|
|
|
|
// validate output if requested
|
|
if validateFlag {
|
|
var cfg *caddy.Config
|
|
err = caddy.StrictUnmarshalJSON(adaptedConfig, &cfg)
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup, fmt.Errorf("decoding config: %v", err)
|
|
}
|
|
err = caddy.Validate(cfg)
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup, fmt.Errorf("validation: %v", err)
|
|
}
|
|
}
|
|
|
|
return caddy.ExitCodeSuccess, nil
|
|
}
|
|
|
|
func cmdValidateConfig(fl Flags) (int, error) {
|
|
configFlag := fl.String("config")
|
|
adapterFlag := fl.String("adapter")
|
|
|
|
// load all additional envs as soon as possible
|
|
err := handleEnvFileFlag(fl)
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup, err
|
|
}
|
|
|
|
// use default config and ensure a config file is specified
|
|
configFlag, err = configFileWithRespectToDefault(caddy.Log(), configFlag)
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup, err
|
|
}
|
|
if configFlag == "" {
|
|
return caddy.ExitCodeFailedStartup,
|
|
fmt.Errorf("input file required when there is no Caddyfile in current directory (use --config flag)")
|
|
}
|
|
|
|
input, _, _, err := LoadConfig(configFlag, adapterFlag)
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup, err
|
|
}
|
|
input = caddy.RemoveMetaFields(input)
|
|
|
|
var cfg *caddy.Config
|
|
err = caddy.StrictUnmarshalJSON(input, &cfg)
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup, fmt.Errorf("decoding config: %v", err)
|
|
}
|
|
|
|
err = caddy.Validate(cfg)
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup, err
|
|
}
|
|
|
|
fmt.Println("Valid configuration")
|
|
|
|
return caddy.ExitCodeSuccess, nil
|
|
}
|
|
|
|
func cmdFmt(fl Flags) (int, error) {
|
|
configFile := fl.Arg(0)
|
|
configFlag := fl.String("config")
|
|
if (len(fl.Args()) > 1) || (configFlag != "" && configFile != "") {
|
|
return caddy.ExitCodeFailedStartup, fmt.Errorf("fmt does not support multiple files %s %s", configFlag, strings.Join(fl.Args(), " "))
|
|
}
|
|
if configFile == "" && configFlag == "" {
|
|
configFile = "Caddyfile"
|
|
} else if configFile == "" {
|
|
configFile = configFlag
|
|
}
|
|
|
|
useImports := fl.Bool("imports")
|
|
|
|
// --imports cannot be used with stdin: there is no source directory to
|
|
// resolve import paths against.
|
|
if useImports && configFile == "-" {
|
|
return caddy.ExitCodeFailedStartup,
|
|
fmt.Errorf("cannot use --imports when reading from stdin (no source directory to resolve imports against)")
|
|
}
|
|
|
|
// as a special case, read from stdin if the file name is "-"
|
|
if configFile == "-" {
|
|
input, err := io.ReadAll(os.Stdin)
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup,
|
|
fmt.Errorf("reading stdin: %v", err)
|
|
}
|
|
fmt.Print(string(caddyfile.Format(input)))
|
|
return caddy.ExitCodeSuccess, nil
|
|
}
|
|
|
|
// --imports: format the root file and all files it references via import
|
|
// directives, then write or print each one.
|
|
if useImports {
|
|
results, err := caddyfile.FormatImports(configFile, caddyfile.FormatOptions{})
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup, fmt.Errorf("formatting imports: %v", err)
|
|
}
|
|
|
|
if fl.Bool("overwrite") {
|
|
if err := overwriteFormattedFiles(results); err != nil {
|
|
return caddy.ExitCodeFailedStartup, err
|
|
}
|
|
return caddy.ExitCodeSuccess, nil
|
|
}
|
|
|
|
originals := make([][]byte, len(results))
|
|
var formattingWarning caddyconfig.Warning
|
|
for i, f := range results {
|
|
original, readErr := os.ReadFile(f.Path)
|
|
if readErr != nil {
|
|
return caddy.ExitCodeFailedStartup, fmt.Errorf("reading file for formatting comparison %s: %v", f.Path, readErr)
|
|
}
|
|
originals[i] = original
|
|
if warning, different := caddyfile.FormattingDifference(f.Path, original); different && formattingWarning.File == "" {
|
|
formattingWarning = warning
|
|
}
|
|
}
|
|
|
|
for i, f := range results {
|
|
fmt.Printf("# %s\n", f.Path)
|
|
if fl.Bool("diff") {
|
|
diff := difflib.Diff(
|
|
strings.Split(string(originals[i]), "\n"),
|
|
strings.Split(string(f.Content), "\n"))
|
|
for _, d := range diff {
|
|
switch d.Delta {
|
|
case difflib.Common:
|
|
fmt.Printf(" %s\n", d.Payload)
|
|
case difflib.LeftOnly:
|
|
fmt.Printf("- %s\n", d.Payload)
|
|
case difflib.RightOnly:
|
|
fmt.Printf("+ %s\n", d.Payload)
|
|
}
|
|
}
|
|
} else {
|
|
fmt.Print(string(f.Content))
|
|
}
|
|
}
|
|
if formattingWarning.File != "" {
|
|
return caddy.ExitCodeFailedStartup, fmt.Errorf(`%s:%d: Caddyfile input is not formatted; Tip: use '--overwrite' to update your Caddyfile in-place instead of previewing it. Consult '--help' for more options`,
|
|
formattingWarning.File,
|
|
formattingWarning.Line,
|
|
)
|
|
}
|
|
return caddy.ExitCodeSuccess, nil
|
|
}
|
|
|
|
input, err := os.ReadFile(configFile)
|
|
if err != nil {
|
|
return caddy.ExitCodeFailedStartup,
|
|
fmt.Errorf("reading input file: %v", err)
|
|
}
|
|
|
|
output := caddyfile.Format(input)
|
|
|
|
if fl.Bool("overwrite") {
|
|
if err := os.WriteFile(configFile, output, 0o600); err != nil { //nolint:gosec // path traversal is not really a thing here, this is either "Caddyfile" or admin-controlled
|
|
return caddy.ExitCodeFailedStartup, fmt.Errorf("overwriting formatted file: %v", err)
|
|
}
|
|
return caddy.ExitCodeSuccess, nil
|
|
}
|
|
|
|
if fl.Bool("diff") {
|
|
diff := difflib.Diff(
|
|
strings.Split(string(input), "\n"),
|
|
strings.Split(string(output), "\n"))
|
|
for _, d := range diff {
|
|
switch d.Delta {
|
|
case difflib.Common:
|
|
fmt.Printf(" %s\n", d.Payload)
|
|
case difflib.LeftOnly:
|
|
fmt.Printf("- %s\n", d.Payload)
|
|
case difflib.RightOnly:
|
|
fmt.Printf("+ %s\n", d.Payload)
|
|
}
|
|
}
|
|
} else {
|
|
fmt.Print(string(output))
|
|
}
|
|
|
|
if warning, diff := caddyfile.FormattingDifference(configFile, input); diff {
|
|
return caddy.ExitCodeFailedStartup, fmt.Errorf(`%s:%d: Caddyfile input is not formatted; Tip: use '--overwrite' to update your Caddyfile in-place instead of previewing it. Consult '--help' for more options`,
|
|
warning.File,
|
|
warning.Line,
|
|
)
|
|
}
|
|
|
|
return caddy.ExitCodeSuccess, nil
|
|
}
|
|
|
|
type formattedFileOverwrite struct {
|
|
path string
|
|
content []byte
|
|
original []byte
|
|
info fs.FileInfo
|
|
}
|
|
|
|
func overwriteFormattedFiles(results []caddyfile.FormattedFile) error {
|
|
files := make([]formattedFileOverwrite, 0, len(results))
|
|
for _, result := range results {
|
|
path, err := filepath.EvalSymlinks(filepath.Dir(result.Path))
|
|
if err != nil {
|
|
return fmt.Errorf("preparing formatted file %s: resolving parent directory: %v", result.Path, err)
|
|
}
|
|
path = filepath.Join(path, filepath.Base(result.Path))
|
|
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return fmt.Errorf("preparing formatted file %s: %v", result.Path, err)
|
|
}
|
|
info, statErr := file.Stat()
|
|
pathInfo, lstatErr := os.Lstat(path)
|
|
original, readErr := io.ReadAll(file)
|
|
closeErr := file.Close()
|
|
if statErr != nil {
|
|
return fmt.Errorf("preparing formatted file %s: stating open file: %v", result.Path, statErr)
|
|
}
|
|
if lstatErr != nil {
|
|
return fmt.Errorf("preparing formatted file %s: checking destination: %v", result.Path, lstatErr)
|
|
}
|
|
if readErr != nil {
|
|
return fmt.Errorf("preparing formatted file %s: reading destination: %v", result.Path, readErr)
|
|
}
|
|
if closeErr != nil {
|
|
return fmt.Errorf("preparing formatted file %s: %v", result.Path, closeErr)
|
|
}
|
|
if pathInfo.Mode()&os.ModeSymlink != 0 || !pathInfo.Mode().IsRegular() || !os.SameFile(info, pathInfo) {
|
|
return fmt.Errorf("refusing to overwrite %s: destination is not the regular file that was opened", result.Path)
|
|
}
|
|
if !bytes.Equal(caddyfile.FormatWithOptions(original, caddyfile.FormatOptions{}), result.Content) {
|
|
return fmt.Errorf("refusing to overwrite %s: file changed after it was formatted", result.Path)
|
|
}
|
|
files = append(files, formattedFileOverwrite{path: path, content: result.Content, original: original, info: info})
|
|
}
|
|
|
|
for i, file := range files {
|
|
if err := atomicReplaceFormattedFile(file); err != nil {
|
|
if i > 0 {
|
|
return fmt.Errorf("overwriting formatted file %s after replacing %d of %d files: %v; configuration files may be partially updated", file.path, i, len(files), err)
|
|
}
|
|
return fmt.Errorf("overwriting formatted file %s before replacing any files: %v", file.path, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func atomicReplaceFormattedFile(file formattedFileOverwrite) (err error) {
|
|
temp, err := os.CreateTemp(filepath.Dir(file.path), ".caddy-fmt-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tempPath := temp.Name()
|
|
defer func() {
|
|
_ = temp.Close()
|
|
if err != nil {
|
|
_ = os.Remove(tempPath)
|
|
}
|
|
}()
|
|
|
|
if _, err = temp.Write(file.content); err != nil {
|
|
return err
|
|
}
|
|
if err = temp.Chmod(file.info.Mode().Perm()); err != nil {
|
|
return err
|
|
}
|
|
if err = temp.Sync(); err != nil {
|
|
return err
|
|
}
|
|
if err = temp.Close(); err != nil {
|
|
return err
|
|
}
|
|
|
|
destination, err := os.Open(file.path)
|
|
if err != nil {
|
|
return fmt.Errorf("reopening destination: %v", err)
|
|
}
|
|
destinationInfo, statErr := destination.Stat()
|
|
pathInfo, lstatErr := os.Lstat(file.path)
|
|
current, readErr := io.ReadAll(destination)
|
|
closeErr := destination.Close()
|
|
if statErr != nil {
|
|
return fmt.Errorf("restating destination: %v", statErr)
|
|
}
|
|
if lstatErr != nil {
|
|
return fmt.Errorf("rechecking destination: %v", lstatErr)
|
|
}
|
|
if readErr != nil {
|
|
return fmt.Errorf("rereading destination: %v", readErr)
|
|
}
|
|
if closeErr != nil {
|
|
return fmt.Errorf("closing destination: %v", closeErr)
|
|
}
|
|
if pathInfo.Mode()&os.ModeSymlink != 0 || !pathInfo.Mode().IsRegular() ||
|
|
!os.SameFile(file.info, destinationInfo) || !os.SameFile(destinationInfo, pathInfo) ||
|
|
!bytes.Equal(current, file.original) {
|
|
return fmt.Errorf("destination changed while formatting")
|
|
}
|
|
if err = os.Rename(tempPath, file.path); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// handleEnvFileFlag loads the environment variables from the given --envfile
|
|
// flag if specified. This should be called as early in the command function.
|
|
func handleEnvFileFlag(fl Flags) error {
|
|
var err error
|
|
var envfileFlag []string
|
|
envfileFlag, err = fl.GetStringSlice("envfile")
|
|
if err != nil {
|
|
return fmt.Errorf("reading envfile flag: %v", err)
|
|
}
|
|
|
|
for _, envfile := range envfileFlag {
|
|
if err := loadEnvFromFile(envfile); err != nil {
|
|
return fmt.Errorf("loading additional environment variables: %v", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// AdminAPIRequest makes an API request according to the CLI flags given,
|
|
// with the given HTTP method and request URI. If body is non-nil, it will
|
|
// be assumed to be Content-Type application/json. The caller should close
|
|
// the response body. Should only be used by Caddy CLI commands which
|
|
// need to interact with a running instance of Caddy via the admin API.
|
|
func AdminAPIRequest(adminAddr, method, uri string, headers http.Header, body io.Reader) (*http.Response, error) {
|
|
parsedAddr, err := caddy.ParseNetworkAddress(adminAddr)
|
|
if err != nil || parsedAddr.PortRangeSize() > 1 {
|
|
return nil, fmt.Errorf("invalid admin address %s: %v", adminAddr, err)
|
|
}
|
|
origin := "http://" + parsedAddr.JoinHostPort(0)
|
|
if parsedAddr.IsUnixNetwork() {
|
|
origin = "http://127.0.0.1" // bogus host is a hack so that http.NewRequest() is happy
|
|
|
|
// the unix address at this point might still contain the optional
|
|
// unix socket permissions, which are part of the address/host.
|
|
// those need to be removed first, as they aren't part of the
|
|
// resulting unix file path
|
|
addr, _, err := internal.SplitUnixSocketPermissionsBits(parsedAddr.Host)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
parsedAddr.Host = addr
|
|
} else if parsedAddr.IsFdNetwork() {
|
|
origin = "http://127.0.0.1"
|
|
}
|
|
|
|
// form the request
|
|
req, err := http.NewRequest(method, origin+uri, body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("making request: %v", err)
|
|
}
|
|
if parsedAddr.IsUnixNetwork() || parsedAddr.IsFdNetwork() {
|
|
// We used to conform to RFC 2616 Section 14.26 which requires
|
|
// an empty host header when there is no host, as is the case
|
|
// with unix sockets and socket fds. However, Go required a
|
|
// Host value so we used a hack of a space character as the host
|
|
// (it would see the Host was non-empty, then trim the space later).
|
|
// As of Go 1.20.6 (July 2023), this hack no longer works. See:
|
|
// https://github.com/golang/go/issues/60374
|
|
// See also the discussion here:
|
|
// https://github.com/golang/go/issues/61431
|
|
//
|
|
// After that, we now require a Host value of either 127.0.0.1
|
|
// or ::1 if one is set. Above I choose to use 127.0.0.1. Even
|
|
// though the value should be completely irrelevant (it could be
|
|
// "srldkjfsd"), if for some reason the Host *is* used, at least
|
|
// we can have some reasonable assurance it will stay on the local
|
|
// machine and that browsers, if they ever allow access to unix
|
|
// sockets, can still enforce CORS, ensuring it is still coming
|
|
// from the local machine.
|
|
} else {
|
|
req.Header.Set("Origin", origin)
|
|
}
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
maps.Copy(req.Header, headers)
|
|
|
|
// make an HTTP client that dials our network type, since admin
|
|
// endpoints aren't always TCP, which is what the default transport
|
|
// expects; reuse is not of particular concern here
|
|
client := http.Client{
|
|
Transport: &http.Transport{
|
|
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
|
|
return net.Dial(parsedAddr.Network, parsedAddr.JoinHostPort(0))
|
|
},
|
|
},
|
|
}
|
|
|
|
resp, err := client.Do(req) //nolint:gosec // the only SSRF here would be self-sabotage I think
|
|
if err != nil {
|
|
return nil, fmt.Errorf("performing request: %v", err)
|
|
}
|
|
|
|
// if it didn't work, let the user know
|
|
if resp.StatusCode >= 400 {
|
|
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024*2))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("HTTP %d: reading error message: %v", resp.StatusCode, err)
|
|
}
|
|
return nil, fmt.Errorf("caddy responded with error: HTTP %d: %s", resp.StatusCode, respBody)
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// DetermineAdminAPIAddress determines which admin API endpoint address should
|
|
// be used based on the inputs. By priority: if `address` is specified, then
|
|
// it is returned; if `config` is specified, then that config will be used for
|
|
// finding the admin address; if `configFile` (and `configAdapter`) are specified,
|
|
// then that config will be loaded to find the admin address; otherwise, the
|
|
// default admin listen address will be returned.
|
|
func DetermineAdminAPIAddress(address string, config []byte, configFile, configAdapter string) (string, error) {
|
|
// Prefer the address if specified and non-empty
|
|
if address != "" {
|
|
return address, nil
|
|
}
|
|
|
|
// Try to load the config from file if specified, with the given adapter name
|
|
if configFile != "" {
|
|
var loadedConfigFile string
|
|
var err error
|
|
|
|
// use the provided loaded config if non-empty
|
|
// otherwise, load it from the specified file/adapter
|
|
loadedConfig := config
|
|
if len(loadedConfig) == 0 {
|
|
// get the config in caddy's native format
|
|
loadedConfig, loadedConfigFile, _, err = LoadConfig(configFile, configAdapter)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if loadedConfigFile == "" {
|
|
return "", fmt.Errorf("no config file to load; either use --config flag or ensure Caddyfile exists in current directory")
|
|
}
|
|
}
|
|
|
|
// get the address of the admin listener from the config
|
|
if len(loadedConfig) > 0 {
|
|
var tmpStruct struct {
|
|
Admin caddy.AdminConfig `json:"admin"`
|
|
}
|
|
err := json.Unmarshal(loadedConfig, &tmpStruct)
|
|
if err != nil {
|
|
return "", fmt.Errorf("unmarshaling admin listener address from config: %v", err)
|
|
}
|
|
if tmpStruct.Admin.Listen != "" {
|
|
return tmpStruct.Admin.Listen, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback to the default listen address otherwise
|
|
return caddy.DefaultAdminListen, nil
|
|
}
|
|
|
|
// configFileWithRespectToDefault returns the filename to use for loading the config, based
|
|
// on whether a config file is already specified and a supported default config file exists.
|
|
func configFileWithRespectToDefault(logger *zap.Logger, configFile string) (string, error) {
|
|
const defaultCaddyfile = "Caddyfile"
|
|
|
|
// if no input file was specified, try a default Caddyfile if the Caddyfile adapter is plugged in
|
|
if configFile == "" && caddyconfig.GetAdapter("caddyfile") != nil {
|
|
_, err := os.Stat(defaultCaddyfile)
|
|
if err == nil {
|
|
// default Caddyfile exists
|
|
if logger != nil {
|
|
logger.Info("using adjacent Caddyfile")
|
|
}
|
|
return defaultCaddyfile, nil
|
|
}
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
// problem checking
|
|
return configFile, fmt.Errorf("checking if default Caddyfile exists: %v", err)
|
|
}
|
|
}
|
|
|
|
// default config file does not exist or is irrelevant
|
|
return configFile, nil
|
|
}
|
|
|
|
type moduleInfo struct {
|
|
caddyModuleID string
|
|
goModule *debug.Module
|
|
err error
|
|
}
|